粘包和拆包

学过TCP的都知道,它是属于传输层的协议,传输层除了有TCP协议外还有UDP协议,但是UDP是不存在拆包和粘包的。UDP是基于报文发送的,从UDP的帧结构可以看出,在UDP首部采用了16bit来指示UDP数据报文的长度,因此在应用层能很好的将不同的数据报文区分开,从而避免粘包和拆包的问题。

TCP是基于字节流的,虽然应用层和TCP传输层之间的数据交互是大小不等的数据块,但是TCP把这些数据块仅仅看成一连串无结构的字节流,没有边界;另外从TCP的帧结构也可以看出,在TCP的首部没有表示数据长度的字段,基于上面两点,在使用TCP传输数据时,才有粘包或者拆包现象发生的可能。

*
  • 服务端分两次读取到了两个独立的数据包,分别是D1和D2没有粘包和拆包

  • 服务端一次接受到两个粘在一起的数据包,D2和D1,被称为TCP粘包服务端分两次读取到了两个数据包,第一次读取到完整的D1,D2部分内容,第二次读取了D2的剩余内容,这被称之为TCP拆包操作

  • 服务端分两次读取到了两个数据包,第一次读取到了D1_1,第二次读取到了D1包的剩余内容和完整的D2数据包

  • 如果此时服务端TCP接收滑窗非常小,而数据包内容相对较大的情况,很可能发生服务端多次拆包才能将D1和D2数据接收完整

产生原因

  • 要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生拆包。

  • 待发送数据大于MSS(最大报文长度),TCP在传输前将进行拆包。

  • 要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去,将会发生粘包。

  • 接收数据端的应用层没有及时读取接收缓冲区中的数据,将发生粘包。

解决方法

通过以上分析,我们清楚了粘包或拆包发生的原因,那么如何解决这个问题呢?解决问题的关键在于如何给每个数据包添加边界信息,常用的方法有如下几个:

  • 发送端给每个数据包添加包首部(类似UDP),首部中应该至少包含数据包的长度,这样接收端在接收到数据后,通过读取包首部的长度字段,便知道每一个数据包的实际长度了

  • 发送端将每个数据包封装为固定长度(不够的可以通过补0填充),这样接收端每次从接收缓冲区中读取固定长度的数据就自然而然的把每个数据包拆分开来。

  • 可以在数据包之间设置边界,如添加特殊符号(如\r\n),这样,接收端通过这个边界就可以将不同的数据包拆分开。

Netty 解决方案

io.netty.handler.codec.callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out)

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
32
33
34
35
protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
try {
while (in.isReadable()) {
int outSize = out.size();
int oldInputLength = in.readableBytes();
decode(ctx, in, out);
// Check if this handler was removed before continuing the loop.
// If it was removed, it is not safe to continue to operate on the buffer.
//
// See https://github.com/netty/netty/issues/1664
if (ctx.isRemoved()) {
break;
}
if (outSize == out.size()) {
if (oldInputLength == in.readableBytes()) {
break;
} else {
continue;
}
}
if (oldInputLength == in.readableBytes()) {
throw new DecoderException(
StringUtil.simpleClassName(getClass()) +
".decode() did not read anything but decoded a message.");
}
if (isSingleDecode()) {
break;
}
}
} catch (DecoderException e) {
throw e;
} catch (Throwable cause) {
throw new DecoderException(cause);
}
}

当上面一个ChannelHandlerContext传入的ByteBuf有数据的时候,这里我们可以把in参数看成网络流,这里有不断的数据流入,而我们要做的就是从这个byte流中分离出message,然后把message添加给out

分开描述下代码逻辑:

  • 当out中有message的时候,直接将out中的内容交给后面的ChannelHandlerContext去处理

  • 当用户逻辑把当前ChannelHandlerContext移除的时候,立即停止对网络数据的处理

  • 调用readableBytes记录当前in中可读字节数

  • decode是抽象方法,交给子类具体实现

  • 判断当前ChannelHandlerContext是移除的时候,立即停止对网络数据的处理

  • 如果子类实现没有分理出任何message的时候,且子类实现也没有动ByteBuf中的数据的时候,这里直接跳出,等待后续有数据来了再进行处理

  • 如果子类实现没有分理出任何message的时候,且子类实现动了ByteBuf的数据,则继续循环,直到解析出message或者不在对ByteBuf中数据进行处理为止

  • 如果子类实现解析出了message但是又没有动ByteBuf中的数据,那么是有问题的,抛出异常。

  • 如果标志位只解码一次,则退出

如果要实现具有处理粘包、拆包功能的子类,及decode实现,必须要遵守上面的规则,我们以实现处理第一部分的第二种粘包情况和第三种情况拆包情况的服务器逻辑来举例:

粘包:decode需要实现的逻辑对应于将客户端发送的两条消息都解析出来分为两个message加入out,这样的话callDecode只需要调用一次decode即可。

拆包:decode需要实现的逻辑主要对应于处理第一个数据包的时候,第一次调用decode的时候outsize不变,从continue跳出并且由于不满足继续可读而退出循环,处理第二个数据包的时候,对于decode的调用将会产生两个message放入out,其中两次进入callDecode上下文中的数据流将会合并为一个ByteBuf和当前ChannelHandlerContext实例关联,两次处理完毕即清空这个ByteBuf

尽管介绍了ByteToMessageDecoder,用户自己去实现处理粘包、拆包的逻辑还是有一定难度的,Netty已经提供了一些基于不同处理粘包、拆包规则的实现,我们可以根据规则自行选择使用Netty提供的Decoder来进行具有粘包、拆包处理功能的网络应用开发。

  • DelimiterBasedFrameDecoder 基于消息边界方式进行粘包拆包处理的。

  • FixedLengthFrameDecoder 基于固定长度消息进行粘包拆包处理的。

  • LengthFieldBasedFrameDecoder 基于消息头指定消息长度进行粘包拆包处理的。

  • LineBasedFrameDecoder 基于行来进行消息粘包拆包处理的。

测试一把

通过例子,来更加清晰的认识TCP粘包/拆包带来的问题,以及使用Netty内置的解决方案解决粘包/拆包的问题

异常情况

上一章的代码,我们改造TimeServerHandler中的channelRead方法

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
private static class TimeServerHandler extends ChannelHandlerAdapter {
private int counter;
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
byte[] req = new byte[buf.readableBytes()];
buf.readBytes(req);
String body = new String(req, "UTF-8").substring(0, req.length - System.getProperty("line.separator").length());
System.out.println("TimeServer 接收到的消息 :" + body + "; 当前统计:" + ++counter);
String currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? String.valueOf(System.currentTimeMillis()) : "BAD ORDER";
currentTime = currentTime + System.getProperty("line.separator");
ByteBuf resp = Unpooled.copiedBuffer(currentTime.getBytes());
ctx.write(resp);
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
//将消息队列中信息写入到SocketChannel中去,解决了频繁唤醒Selector所带来不必要的性能开销
//Netty的 write 只是将消息放入缓冲数组,再通过调用 flush 才会把缓冲区的数据写入到 SocketChannel
ctx.flush();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.close();//发生异常时候,执行重写后的 exceptionCaught 进行资源关闭
}
}

改造TimeClientHandler代码

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
private static class TimeClientHandler extends ChannelHandlerAdapter {
private byte[] req;
public TimeClientHandler() {
req = ("QUERY TIME ORDER" + System.getProperty("line.separator")).getBytes();
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ByteBuf message = null;
for (int i = 0; i < 100; i++) {
message = Unpooled.buffer(req.length);
message.writeBytes(req);
ctx.writeAndFlush(message);
}
}
private int counter;
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
byte[] req = new byte[buf.readableBytes()];
buf.readBytes(req);
String body = new String(req, "UTF-8");
System.out.println("TimeClient 接收到的消息 :" + body + "; 当前统计:" + ++counter);
ctx.close();//接受完消息关闭连接
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("释放资源:" + cause.getMessage());//不重写将会看到堆栈信息以及资源无法关闭
ctx.close();
}
}

分别启动TimeServerTimeClient两个程序

1
2
3
4
5
6
7
8
9
10
11
绑定端口,同步等待成功......
TimeServer 接收到的消息 :QUERY TIME ORDER
QUERY TIME ORDER
......省略部分 QUERY TIME ORDER
QUERY TIME ORDER
QUERY TIME ORDER
QUERY TIME ORD; 当前统计:1
QUERY TIME ORDER
TimeServer 接收到的消息 :
......省略部分 QUERY TIME ORDER
QUERY TIME ORDER; 当前统计:2
1
2
3
TimeClient 接收到的消息 :BAD ORDER
BAD ORDER
; 当前统计:1

从上面的日志中,我们可以发现服务端发生TCP粘包的情况,正确情况应该是服务端输出100条含TimeServer 接收到的消息 :QUERY TIME ORDER; 当前统计:counter的日志,而且客户端只接收了部分断断续续的数据,说明返回时也发生了粘包…

解决之道

我们在上文说了LineBasedFrameDecoder是一个基于行的解码器,从源码中可以看到它是根据\n或者\r\n判断的,当ByteBuf存在这样的字符就认为是一个完整的数据包,这样可以有效的避免数据粘包或者拆包的情况,从而保证我们消息的有效传输,接下来我们就玩一玩NettyLineBasedFrameDecoder…..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* A decoder that splits the received {@link ByteBuf}s on line endings.
* <p>
* Both {@code "\n"} and {@code "\r\n"} are handled.
* For a more general delimiter-based decoder, see {@link DelimiterBasedFrameDecoder}.
*/
public class LineBasedFrameDecoder extends ByteToMessageDecoder {
protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception {
final int eol = findEndOfLine(buffer);
if (!discarding) {
if (eol >= 0) {
final ByteBuf frame;
final int length = eol - buffer.readerIndex();
final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;
}
}
......
}
}

修改TimeServerChildChannelHandlerTimeServerHandler内部类,添加了LineBasedFrameDecoderStringDecoder两个解码器,同时向客户端回写系统当前时间戳,记得我们这里是用\n做换行处理的

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
private static class ChildChannelHandler extends ChannelInitializer {
@Override
protected void initChannel(Channel channel) throws Exception {
channel.pipeline().addLast(new LineBasedFrameDecoder(1024));//划重点了,拿笔记下
channel.pipeline().addLast(new StringDecoder());
channel.pipeline().addLast(new TimeServerHandler());
}
private static class TimeServerHandler extends ChannelHandlerAdapter {
private int counter;
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
String body = (String) msg;
System.out.println("TimeServer 接收到的消息 :" + body + "; 当前统计:" + ++counter);
String currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? String.valueOf(System.currentTimeMillis())+"\n" : "BAD ORDER";
ByteBuf resp = Unpooled.copiedBuffer(currentTime.getBytes());
ctx.write(resp);
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
//将消息队列中信息写入到SocketChannel中去,解决了频繁唤醒Selector所带来不必要的性能开销
//Netty的 write 只是将消息放入缓冲数组,再通过调用 flush 才会把缓冲区的数据写入到 SocketChannel
ctx.flush();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.close();//发生异常时候,执行重写后的 exceptionCaught 进行资源关闭
}
}
}

修改TimeClientconnecthandler内部类

1
2
3
4
5
protected void initChannel(SocketChannel channel) throws Exception {
channel.pipeline().addLast(new LineBasedFrameDecoder(1024));//划重点了,拿笔记下
channel.pipeline().addLast(new StringDecoder());
channel.pipeline().addLast(new TimeClientHandler());
}

修改TimeClientHandler中的channelRead读取数据的方法

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
private static class TimeClientHandler extends ChannelHandlerAdapter {
private byte[] req;
private int counter;
public TimeClientHandler() {
req = ("QUERY TIME ORDER\n").getBytes();
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ByteBuf message = null;
for (int i = 0; i < 100; i++) {
message = Unpooled.buffer(req.length);
message.writeBytes(req);
ctx.writeAndFlush(message);
}
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
String body = (String) msg;
System.out.println("TimeClient 接收到的消息 :" + body + "; 当前统计:" + ++counter);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("释放资源:" + cause.getMessage());//不重写将会看到堆栈信息以及资源无法关闭
ctx.close();
}
}

分别启动TimeServerTimeClient两个程序

1
2
3
4
5
6
绑定端口,同步等待成功......
TimeServer 接收到的消息 :QUERY TIME ORDER; 当前统计:1
TimeServer 接收到的消息 :QUERY TIME ORDER; 当前统计:2
.......此处省略一大堆日志
TimeServer 接收到的消息 :QUERY TIME ORDER; 当前统计:99
TimeServer 接收到的消息 :QUERY TIME ORDER; 当前统计:100
1
2
3
4
5
TimeClient 接收到的消息 :1504274365781; 当前统计:1
TimeClient 接收到的消息 :1504274365785; 当前统计:2
.......此处省略一大堆日志
TimeClient 接收到的消息 :1504274365785; 当前统计:99
TimeClient 接收到的消息 :1504274365785; 当前统计:100

利用GitHub的OAuth授权实战

身份注册

要想得到一个网站的OAuth授权,必须要到它的网站进行身份注册,拿到应用的身份识别码 ClientIDClientSecret

传送门https://github.com/settings/applications/new

有几个必填项。

  • Application name:我们的应用名;

  • Homepage URL:应用主页链接;

  • Authorization callback URL:这个是github 回调我们项目的地址,用来获取授权码和令牌

提交后会看到就可以看到客户端ClientID 和客户端密匙ClientSecret,到这我们的准备工作就完事了

授权开发

第1步:客户端向资源拥有者发送授权请求,一般资源拥有者的资源会存放在资源服务器

我们在这里新建一个html界面,用来模拟登录请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Page</title>
<script>
function login() {
alert("登录")
window.location.href = 'https://github.com/login/oauth/authorize?client_id=a98e2191927c000f4eac&redirect_uri=http://localhost:8080/authorize/redirect'
}
</script>
</head>
<body>
<h1>Welcome to the Login Page</h1>
<button onclick="login()">Login</button>
</body>
</html>
第2步:客户端会收到资源服务器的授权许可
当我们点击登录按钮,会提示让我们授权,同意授权后会重定向到authorize/redirect,并携带授权码code;如果之前已经同意过,会跳过这一步直接回调第3步:客户端拿到许可之后,再向授权服务器发送一次验证,给客户端颁发一个Access Token访问令牌

授权后紧接着就要回调 adminflow 网站接口,拿到授权码以后拼装获取令牌 access_token的请求链接,这时会用到客户端密匙client_secret

第4步:客户端拿到令牌之后,交给资源服务器
**第5步:**资源服务器会将获取到的令牌传给认证服务器验证令牌的有效性。
**第6步:**资源服务器验证令牌通过之后,就会返回一个受保护的资源。
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
32
33
34
35
36
37
38
39
40
41
42
43
package org.pt.controller;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;


@RestController
public class UserController {
@Value("${github.clientId}")
private String clientId;

@Value("${github.clientSecret}")
private String clientSecret;


@GetMapping("/authorize/redirect")
public String githubCallback(@RequestParam("code") String code) {
String tokenUrl = "https://github.com/login/oauth/access_token";
String requestBody = "client_id=" + clientId + "&client_secret=" + clientSecret + "&code=" + code;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
HttpEntity<String> requestEntity = new HttpEntity<>(requestBody, headers);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate.exchange(tokenUrl, HttpMethod.POST, requestEntity, String.class);
if (response.getStatusCode() == HttpStatus.OK) {
// Extract access token from response body
String[] parts = response.getBody().split("&");
for (String part : parts) {
System.out.println(part);
if (part.startsWith("access_token")) {
String[] split = part.split("=");
return split[1];
}
}
}
return null;
}

}

我们这里拿到许可之后,向服务器发送一次验证,获取access toke,有了令牌以后开始获取用户信息,在 API 中要带上access_token

利用postman发送请求,请求地址为https://api.github.com/user

加入Authorization,Type为Beare 值为返回的access token

请求结果

返回的用户信息是 JSON 数据格式,如果想把数据传递给前端,可以通过 url 重定向到前端页面,将数据以参数的方式传递

源代码https://github.com/Breeze1203/JavaAdvanced/tree/main/springboot-demo/spring-boot-oAuth2

文章目录

  • DB Version

  • Table

  • 数据量

  • 案例一 :explain select * from employees where name = ‘LiLei’ and position = ‘dev’ order by age

  • 案例二: explain select * from employees where name = ‘LiLei’ order by position

  • 案例三:explain select * from employees where name = ‘LiLei’ order by age , position

  • 案例四:explain select * from employees where name = ‘LiLei’ order by position , age

  • 案例五:explain select * from employees where name = ‘LiLei’ and age = 18 order by position , age ;

  • 案例六:explain select * from employees where name = ‘LiLei’ order by age asc , position desc ;

  • 案例七:explain select * from employees where name in (‘HanMeiMei’ , ‘LiLei’) order by age , position ;

  • 案例八: explain select * from employees where name > ‘HanMeiMei’ order by name ;

  • group by 优化

  • 小结

DB Version

1
2
3
4
5
6
7
8
9
mysql> select version();
+-----------+
| version() |
+-----------+
| 5.7.28 |
+-----------+
1 row in set

mysql>

Table

1
2
3
4
5
6
7
8
9
CREATE TABLE employees (
id int(11) NOT NULL AUTO_INCREMENT,
name varchar(24) NOT NULL DEFAULT '' COMMENT '姓名',
age int(11) NOT NULL DEFAULT '0' COMMENT '年龄',
position varchar(20) NOT NULL DEFAULT '' COMMENT '职位',
hire_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入职时间',
PRIMARY KEY (id),
KEY idx_name_age_position (name,age,position) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='员工记录表';

两个索引

1、 主键索引;
2、 二级索引KEYidx_name_age_position(name,age,position)USINGBTREE;

重点就是这个二级索引 ,记号了哈。


数据量

1
2
3
4
5
6
7
8
9
mysql> select count(1) from employees ;
+----------+
| count(1) |
+----------+
| 100002 |
+----------+
1 row in set

mysql>

案例一 :explain select * from employees where name = ‘LiLei’ and position = ‘dev’ order by age

1
explain select * from employees where name = 'LiLei' and position = 'dev' order by age ;

先想一下这个order by 会不会走索引 ?

*

会走索引

原因呢?

脑海中要有这个联合索引在MySQL底层的B+Tree的数据结构 , 索引 排好序的数据结构。

*

name = ‘LiLei’ and position = ‘dev’ order by age

name 为 LiLei , name 确定的情况下, age 肯定是有序的 ,age 有序不能保证position 有序

所以这个order by age 是可以走索引的

继续分析下这个explain

1
2
3
4
5
6
7
mysql> explain select * from employees where name = 'LiLei' and position = 'dev' order by age ;
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+-----------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+-----------------------+
| 1 | SIMPLE | employees | NULL | ref | idx_name_age_position | idx_name_age_position | 74 | const | 1 | 10 | Using index condition |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+-----------------------+
1 row in set

order by 走的索引 是不会体现在key_len上的, 这个74 = 3 * 24 + 2 , 是计算的name 。 最左匹配原则 ,中间字段不能断,因此查询用到了name索引。

但是Extra直接里面可以看出来 Using index condition ,说明age索引列用在了排序过程中 。 如果没有走索引的话,那就是 Using FileSort 了

接下来继续看几个例子,加深理解,重点是脑海中的 索引B+Tree结构


案例二: explain select * from employees where name = ‘LiLei’ order by position

1
mysql> explain select * from employees where  name = 'LiLei' order by position ;     

想一想,这个order by 会走索引吗?

我们来看下索引 KEY idx_name_age_position (name,age,position) USING BTREE

再来看下查询SQL

1
where  name = 'LiLei' order by position ;  

name = LiLei , name 值能确定下来, 符合最左匹配原则 所以查询会走索引 , 用了联合索引中的name字段, key len = 74 . 所以 Using index condition

order by position , 在索引中 中间缺失了age , 用position ,跳过了age , 那索引树能是有序的吗? 肯定不是。。。所以 position肯定不是排好序的 , 无法走索引排序,因此 Extra信息 有 Using filesort

来看下执行计划

1
2
3
4
5
6
7
8
9
mysql> explain select * from employees where  name = 'LiLei' order by position ;     
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+---------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+---------------------------------------+
| 1 | SIMPLE | employees | NULL | ref | idx_name_age_position | idx_name_age_position | 74 | const | 1 | 100 | Using index condition; Using filesort |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+---------------------------------------+
1 row in set

mysql>

正如分析~

有感觉了吗? 再来看一个


案例三:explain select * from employees where name = ‘LiLei’ order by age , position

这个SQL和案例二的很相似 , 仅仅在排序的时候在前面多了一个age字段参与排序 , 那分析分析 order by 会走索引吗

1
mysql> explain select * from employees where  name = 'LiLei' order by age , position ;

时刻不要那个索引树 ,来分析一下

name = LiLei , name 固定,结合 建立的索引, 最左原则,所以查询肯定会走联合索引中的部分索引 name .

在name都是LiLei 的情况下 , order by age , position 结合索引树 ,age和position用于排序 也是有序的,应该不会走using filesort

我们来看下执行计划

*
1
2
3
4
5
6
7
8
9
mysql> explain select * from employees where  name = 'LiLei' order by age , position ;
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+-----------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+-----------------------+
| 1 | SIMPLE | employees | NULL | ref | idx_name_age_position | idx_name_age_position | 74 | const | 1 | 100 | Using index condition |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+-----------------------+
1 row in set

mysql>

案例四:explain select * from employees where name = ‘LiLei’ order by position , age

再分析一个,和案例上也很像。 把 order by的排序顺序 调整一下,我们来分析一下 order by会不会走索引

1
explain select * from employees where  name = 'LiLei' order by position , age ;   
*
1
2
3
4
5
6
7
8
9
mysql> explain select * from employees where  name = 'LiLei' order by position , age ;   
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+---------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+---------------------------------------+
| 1 | SIMPLE | employees | NULL | ref | idx_name_age_position | idx_name_age_position | 74 | const | 1 | 100 | Using index condition; Using filesort |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+---------------------------------------+
1 row in set

mysql>

咦,执行计划中有 using filesort

为什么呢?

看看我们二级索引的建立的字段顺序 , 创建顺序为name,age,position,但是排序的时候age和position颠倒位置了, 那排好序的特性肯定就无法满足了,那你让MySQL怎么走索引?


案例五:explain select * from employees where name = ‘LiLei’ and age = 18 order by position , age ;

这个order by 会走索引吗?

*
1
2
3
4
5
6
7
8
9
mysql> explain select * from employees where name = 'LiLei' and age = 18 order by position , age ;
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------------+------+----------+-----------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------------+------+----------+-----------------------+
| 1 | SIMPLE | employees | NULL | ref | idx_name_age_position | idx_name_age_position | 78 | const,const | 1 | 100 | Using index condition |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------------+------+----------+-----------------------+
1 row in set

mysql>

走了dx_name_age_position 索引中的 name 和 age , order by 其实也走了索引,你看extra中并没有 using filesort ,因为age为常量,在排序中被MySQL优化了,所以索引未颠倒,不会出现Using filesort


案例六:explain select * from employees where name = ‘LiLei’ order by age asc , position desc ;

1
2
3
4
5
6
7
mysql> explain select * from employees where name = 'LiLei'  order by  age asc , position desc ;  
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+---------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+---------------------------------------+
| 1 | SIMPLE | employees | NULL | ref | idx_name_age_position | idx_name_age_position | 74 | const | 1 | 100 | Using index condition; Using filesort |
+----+-------------+-----------+------------+------+-----------------------+-----------------------+---------+-------+------+----------+---------------------------------------+
1 row in set
*

我们可以看到虽然排序的字段列与建立索引的顺序一样, order by默认升序排列,而SQL中的 position desc变成了降序排列,导致与索引的排序方式不同,从而产生Using filesort。

Note: Mysql8以上版本有降序索引可以支持该种查询方式。


案例七:explain select * from employees where name in (‘HanMeiMei’ , ‘LiLei’) order by age , position ;

1
2
3
4
5
6
7
8
9
mysql> explain select * from employees where name in ('HanMeiMei' , 'LiLei')  order by  age  , position  ;    
+----+-------------+-----------+------------+-------+-----------------------+-----------------------+---------+------+------+----------+---------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+-------+-----------------------+-----------------------+---------+------+------+----------+---------------------------------------+
| 1 | SIMPLE | employees | NULL | range | idx_name_age_position | idx_name_age_position | 74 | NULL | 2 | 100 | Using index condition; Using filesort |
+----+-------------+-----------+------------+-------+-----------------------+-----------------------+---------+------+------+----------+---------------------------------------+
1 row in set

mysql>

对order by 来讲 ,多个相等的条件也是 范围查询。 既然是范围查询, 可能对于每个值在索引中是有序的,但多个合并在一起,就不是有序的了,所以 using filesort .


案例八: explain select * from employees where name > ‘HanMeiMei’ order by name ;

1
2
3
4
5
6
7
8
9
mysql> explain select * from employees where name > 'HanMeiMei'  order by  name  ;       
+----+-------------+-----------+------------+------+-----------------------+------+---------+------+-------+----------+-----------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+------+-----------------------+------+---------+------+-------+----------+-----------------------------+
| 1 | SIMPLE | employees | NULL | ALL | idx_name_age_position | NULL | NULL | NULL | 96845 | 50 | Using where; Using filesort |
+----+-------------+-----------+------------+------+-----------------------+------+---------+------+-------+----------+-----------------------------+
1 row in set

mysql>

MySQL自己内部有一套优化机制,且数据量不同、版本不一样,结果也可能有差异

一般情况下, 联合索引第一个字段用范围不一定会走索引 , 可以采用 覆盖索引进行优化,避免回表带来的性能开销 。

1
2
3
4
5
6
7
8
9
10
mysql> explain select name
from employees where name > 'a' order by name ;
+----+-------------+-----------+------------+-------+-----------------------+-----------------------+---------+------+-------+----------+--------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+-------+-----------------------+-----------------------+---------+------+-------+----------+--------------------------+
| 1 | SIMPLE | employees | NULL | range | idx_name_age_position | idx_name_age_position | 74 | NULL | 48422 | 100 | Using where; Using index |
+----+-------------+-----------+------------+-------+-----------------------+-----------------------+---------+------+-------+----------+--------------------------+
1 row in set

mysql>
*

group by 优化

  • group by与order by类似,其实质是先排序后分组遵照索引创建顺序的最左前缀法则

  • 对于group by的优化如果不需要排序的可以加上order by null禁止排序

  • where高于having,能写在where中的限定条件就不要去having限定了。


小结

  • MySQL支持两种方式的排序filesort和index,Using index是指MySQL扫描索引本身完成排序

  • order by满足两种情况会使用Using index
    A: order by语句使用索引最左前列。
    B: 使用where子句与order by子句条件列组合满足索引最左前列

  • 尽量在索引列上完成排序,遵循索引建立(索引创建的顺序)时的最左前缀法则

  • 如果order by的条件不在索引列上,就会产生Using filesort

  • 能用覆盖索引尽量用覆盖索引

简介

首先了解Spring Date JPA是什么?

SpringData:其实SpringData就是Spring提供了一个操作数据的框架。而SpringData JPA只是SpringData框架下的一个基于JPA标准操作数据的模块。
SpringData JPA:基于JPA的标准数据进行操作。简化操作持久层的代码。只需要编写接口就可以。

JPA是Spring Data下的子项目,JPA是Java Persistence API的简称,中文名为Java持久层API,是JDK 5.0注解或XML描述对象-关系表的映射关系,并将运行期的实体对象持久化到数据库中

你可以理解为JPA和Mybatis是起相同作用的,都是持久层的框架,但是由于现在Mybatis的广泛应用,现在了解和使用JPA的人较少.

但在我使用的过程中,也发现其一些优势.

整合

1. 导入jar包

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

2. yml配置文件

1
2
3
4
5
6
7
8
9
10
11
spring:
datasource:
url: jdbc:mysql://localhost:3306/mytest
type: com.alibaba.druid.pool.DruidDataSource
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver //驱动
jpa:
hibernate:
ddl-auto: update //自动更新
show-sql: true //日志中显示sql语句

这里注意:
jpa:hibernate:ddl-auto: update是hibernate的配置属性,其主要作用是:自动创建、更新、验证数据库表结构。该参数的几种配置如下:

  1. create:每次加载hibernate时都会删除上一次的生成的表,然后根据你的model类再重新来生成新表,哪怕两次没有任何改变也要这样执行,这就是导致数据库表数据丢失的一个重要原因。

  2. create-drop:每次加载hibernate时根据model类生成表,但是sessionFactory一关闭,表就自动删除。

  3. update:最常用的属性,第一次加载hibernate时根据model类会自动建立起表的结构(前提是先建立好数据库),以后加载hibernate时根据model类自动更新表结构,即使表结构改变了但表中的行仍然存在不会删除以前的行。要注意的是当部署到服务器后,表结构是不会被马上建立起来的,是要等应用第一次运行起来后才会。

  4. validate:每次加载hibernate时,验证创建数据库表结构,只会和数据库中的表进行比较,不会创建新表,但是会插入新值。

我在初次创建时会设为create,创建好后改为validate.

3.实体类

既然上边的参数可以帮助我们自动的去通过实体类来创建维护表,那么实体类该怎么写呢,又是怎么建立与表的映射

简单的创建一个实体类:get/set方法由注解实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
@Getter
@Setter
@Table(name = "person")
public class Person {

@Id
@GeneratedValue
private Long id;

@Column(name = "name", length = 20)
private String name;

@Column(name = "agee", length = 4)
private int age;
}

创建好实体类并标注好注解后启动主启动类,应该就会在你配置的数据库中自动生成表.

4. Repository接口

personRepository接口如下,

若只是简单的对单表进行crud只需要继承JpaRepository接口,传递了两个参数:1.实体类,2.实体类中主键类型

1
2
public interface PersonRepository extends JpaRepository<Person, Long> {
}

但是当然,我们在工作使用中,不可能只是简单的根据字段查一下就可以了,当你需要传入整个实体类,在根据其中的所有属性进行动态复杂查询,那仅仅继承这个接口就不能满足我们的需求了,

就需要我们再去继承JpaSpecificationExecutor<T>该接口,泛型内传入实体类,只要简单实现toPredicate方法就可以实现复杂的查询,

该接口中提供了几个方法:

1
2
3
4
5
6
7
8
9
10
11
12
public interface JpaSpecificationExecutor<T> {

T findOne(Specification<T> spec);

List<T> findAll(Specification<T> spec);

Page<T> findAll(Specification<T> spec, Pageable pageable);

List<T> findAll(Specification<T> spec, Sort sort);

long count(Specification<T> spec);
}

方法中的Specification就是需要我们传进去的参数,它是一个接口,也是我们实现复杂查询的关键,其中只有一个方法toPredicate

1
2
3
4
public interface Specification<T> {

Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}

5. Controller

然后我们可以直接在controller中编写代码即可(如果业务复杂,当然假如service层也是最好).

简单crud:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
@RequestMapping(value = "person")
public class PerconController {

@Autowired
private PersonRepository personRepository;

@PostMapping(path = "addPerson")
public void addPerson(Person person) {
personRepository.save(person);
}

@DeleteMapping(path = "deletePerson")
public void deletePerson(Long id) {
personRepository.delete(id);
}
}

简单的crud甚至不需要在Repository中写代码,JpaRepository中已有封装好的直接使用即可.

那么我们怎么自己去编写一些简单的代码呢?

我们以根据name查询person为例:
在repository接口中添加如下查询方法:

  1. 注意方法名一定是findBy+属性名

    Person findByName(String name);
    

    还需要注意根据ID查找的findById是不用自己添加方法的,由接口已经封装,但是源码中返回的是Optional 类型。那么这个时候该如何获得T 实体类类型呢,只需要get()即可,就是findById(Id).get() 即返回T类型

  2. 除了添加findBy这种不用写sql的方法外,还有一种可以自己编写sql的方法:

    可以在所添加的方法上通过@Query注解,在value属性上写sql语句来完成对数据库的操作,

    带参查询:(1、根据参数位置2、根据Param注解)

       /**
         * 查询根据参数位置
         * @param name
         * @return
         */
        @Query(value = "select * from person  where name = ?1",nativeQuery = true)
        Person findPersonByName(String Name);
     
        /**
         * 查询根据Param注解
         * @param name
         * @return
         */
        @Query(value = "select p from person p where p.uname = :name")
        Person findPersonByNameTwo(@Param("name") String name);
    

    相信大家也注意到,在@Query中传入了一个属性nativeQuery,

    • @Query有nativeQuery=true,表示可执行的原生sql,原生sql指可以直接复制sql语句给参数赋值就能运行

    • @Query无nativeQuery=true, 表示不是原生sql,查询语句中的表名则是对应的项目中实体类的类名

注意:

对于自定义sql的删改方法,在方法上还要添加@Transactional/@Modifying注解,如下所示:

@Transactional
@Modifying
@Query(value = "delete from Account where id =?1",nativeQuery = true)
void delAccount(int id);

这里去了解了一下其生成sql的原理:

其实JPA在这里遵循Convention over configuration(约定大约配置)的原则,遵循spring 以及JPQL定义的方法命名。Spring提供了一套可以通过命名规则进行查询构建的机制。这套机制会把方法名首先过滤一些关键字,比如 find…By, read…By, query…By, count…By 和 get…By 。系统会根据关键字将命名解析成2个子语句,第一个 By 是区分这两个子语句的关键词。这个 By 之前的子语句是查询子语句(指明返回要查询的对象),后面的部分是条件子语句。如果直接就是 findBy… 返回的就是定义Respository时指定的领域对象集合,同时JPQL中也定义了丰富的关键字:and、or、Between等等,下面我们来看一下JPQL中有哪些关键字:

Keyword Sample JPQL snippet

  1. And—-findByLastnameAndFirstname—-where x.lastname = ?1 and

  2. Or—-findByLastnameOrFirstname—-where x.lastname = ?1 or x.firstname = ?2

  3. Is,Equals—-findByFirstnameIs,findByFirstnameEquals—-where x.firstname = ?1

  4. Between—-findByStartDateBetween—-where x.startDate between ?1 and ?2

  5. LessThan—-findByAgeLessThan—-where x.age < ?1

  6. LessThanEqual—-findByAgeLessThanEqual—-where x.age ⇐ ?1

  7. GreaterThan—-findByAgeGreaterThan—-where x.age > ?1

  8. GreaterThanEqual—-findByAgeGreaterThanEqual—-where x.age >= ?1

  9. After—-findByStartDateAfter—-where x.startDate > ?1

  10. Before—-findByStartDateBefore—-where x.startDate < ?1

  11. IsNull—-findByAgeIsNull—-where x.age is null

  12. IsNotNull,NotNull—-findByAge(Is)NotNull—-where x.age not null

  13. Like—-findByFirstnameLike—-where x.firstname like ?1

  14. NotLike—-findByFirstnameNotLike—-where x.firstname not like ?1

  15. StartingWith—-findByFirstnameStartingWith—-where x.firstname like ?1 (parameter bound with appended %)

  16. EndingWith—-findByFirstnameEndingWith—-where x.firstname like ?1 (parameter bound with prepended %)

  17. Containing—-findByFirstnameContaining—-where x.firstname like ?1 (parameter bound wrapped in %)

  18. OrderBy—-findByAgeOrderByLastnameDesc—-where x.age = ?1 order by x.lastname desc

  19. Not—-findByLastnameNot—-where x.lastname <> ?1

  20. In—-findByAgeIn(Collection ages)—-where x.age in ?1

  21. NotIn—-findByAgeNotIn(Collection age)—-where x.age not in ?1

  22. TRUE—-findByActiveTrue()—-where x.active = true

  23. FALSE—-findByActiveFalse()—-where x.active = false

  24. IgnoreCase—-findByFirstnameIgnoreCase—-where UPPER(x.firstame) = UPPER(?1)

复杂crud:

复杂crud的查询是依靠JpaSpecificationExecutor<T>接口,以及specification的toPredicate方法来添加条件,上文中也基本介绍过,所以在这里就简单贴一下代码,大家根据例子,应该就可以自己写了:

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
32
33
public List<Flow> queryFlows(int pageNo, int pageSize, String status, String userName, Date createTimeStart, Date createTimeEnd) {
List<Flow> result = null;

// 构造自定义查询条件
Specification<Flow> queryCondition = new Specification<Flow>() {
@Override
public Predicate toPredicate(Root<Flow> root, CriteriaQuery<?> criteriaQuery, CriteriaBuilder criteriaBuilder) {
List<Predicate> predicateList = new ArrayList<>();
if (userName != null) {
predicateList.add(criteriaBuilder.equal(root.get("currentOperator"), userName));
}
if (status != null) {
predicateList.add(criteriaBuilder.equal(root.get("status"), status));
}
if (createTimeStart != null && createTimeEnd != null) {
predicateList.add(criteriaBuilder.between(root.get("createTime"), createTimeStart, createTimeEnd));
}
if (orderId!= null) {
predicateList.add(criteriaBuilder.like(root.get("orderId"), "%" + orderId+ "%"));}
return criteriaBuilder.and(predicateList.toArray(new Predicate[predicateList.size()]));
}
};

// 分页和不分页,这里按起始页和每页展示条数为0时默认为不分页,分页的话按创建时间降序

if (pageNo == 0 && pageSize == 0) {
result = flowRepository.findAll(queryCondition);
} else {
result = flowRepository.findAll(queryCondition, PageRequest.of(pageNo - 1, pageSize, Sort.by(Sort.Direction.DESC, "createTime"))).getContent();
}

return result;
}

理解了之后其实很简单,上边主要就是两部:1.先将你所需要的条件加到predicate集合中去,例子中也有equal/between/like相等/区间/模糊,基本也是平常使用的几个,添加好条件后2.进行了分页,判断有没有传入分页的参数,所有传了就分页,没传就查全部,分页中有一个getContent(),可以不加,不加的话还会返回页数/总条数等一些分页的参数,加这个方法就只返回list集合.

补充

分页

在上边说复杂查询的Repository接口时,其中的findAll方法,多传递一个pageable参数就可以自动的提供分页(pageable包含pageIndex和pageSize),相比较来说,省去了再引入pageHelper的步骤,更加简便.

但是这只是在复杂情况下进行一个分页,可是如果我们只是较简单的查询情况,例如只是用@Query注解来进行原生sql的查询时,该怎么去分页呢?

这篇文章中如果用@Query查询再进行分页时,一定要再@Query中添加countQuery属性,该属性通过查询去获取分页总数,这样分页就没问题了!

countQuery代表当前分页的总页数,如果不设置这个参数相信你的分页一定不顺利

如下所示:

1
2
3
4
@Query(nativeQuery = true,
value = "select id, name,age FROM people WHERE id=?! and name=?2 and age=?3,
countQuery = "select count(*) FROM people WHERE id=?! and name=?2 and age=?3")
public Page findAll(Integer id,String name,Integer age,Pageable pageable);

如果既要分组group by,还要分页countQuery就需要: countQuery = "select count(*) FROM (select count(*) FROM people WHERE id=?! and name=?2 and age=?3 group by name) a"最后的a为别名,随意命名

如果不用()包裹再count(),返回的就是分组后每一行的count(),所以就需要再进行一步count()计算,才是分页的总数

注意,返回的Page对象要添加泛型,去规定返回的数据类型,若是没有泛型,返回的就是数组而不是对象.

但是需要注意:new PageRequest会发现已经过时,替代的方法是不要new PageRequest,而是直接用 PageRequest.of这个方法 根据你的需求选择入参;

SpringAop

注解配置方式
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
package com.xxxx.aspect;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;

/**
* 切面
* 切入点和通知的抽象
* 定义 切入点 和 通知
* 切入点:定义要拦截哪些类的哪些方法
* 通知:定义了拦截之后方法要做什么
*/
@Component // 将对象交给IOC容器进行实例化
@Aspect // 声明当前类是一个切面
public class LogCut {

/**
* 切入点
* 定义要拦截哪些类的哪些方法
* 匹配规则,拦截什么方法
*
* 定义切入点
* @Pointcut("匹配规则")
*
* Aop切入点表达式
* 1. 执行所有的公共方法
* execution(public *(..))
* 2. 执行任意的set方法
* execution(* set*(..))
* 3. 设置指定包下的任意类的任意方法 (指定包:com.xxxx.service)
* execution(* com.xxxx.service.*.*(..))
* 4. 设置指定包及子包下的任意类的任意方法 (指定包:com.xxxx.service)
* execution(* com.xxxx.service..*.*(..))
*
* 表达式中的第一个*
* 代表的是方法的修饰范围 (public、private、protected)
* 如果取值是*,则表示所有范围
*/
@Pointcut("execution(* com.xxxx.service..*.*(..))")
public void cut(){

}

/**
* 声明前置通知,并将通知应用到指定的切入点上
* 目标类的方法执行前,执行该通知
*/
@Before(value = "cut()")
public void before() {
System.out.println("前置通知...");
}

/**
* 声明返回通知,并将通知应用到指定的切入点上
* 目标类的方法在无异常执行后,执行该通知
*/
@AfterReturning(value = "cut()")
public void afterReturn(){
System.out.println("返回通知...");
}

/**
* 声明最终通知,并将通知应用到指定的切入点上
* 目标类的方法在执行后,执行该通知 (有异常和无异常最终都会执行)
*/
@After(value = "cut()")
public void after(){
System.out.println("最终通知...");
}

/**
* 声明异常通知,并将通知应用到指定的切入点上
* 目标类的方法在执行异常时,执行该通知
*/
@AfterThrowing(value = "cut()", throwing = "e")
public void afterThrow(Exception e){
System.out.println("异常通知... ===== 异常原因:" + e.getMessage());
}


/**
* 声明环绕通知,并将通知应用到指定的切入点上
* 目标类的方法执行前后,都可通过环绕通知定义响应的处理
* 需要通过显式调用的方法,否则无法访问指定方法 pjp.proceed();
* @param pjp
* @return
*/
@Around(value = "cut()")
public Object around(ProceedingJoinPoint pjp) {
System.out.println("环绕通知-前置通知...");

Object object = null;
//object = pjp.proceed();

try{
// 显式调用对应的方法
object = pjp.proceed();
System.out.println(pjp.getTarget());
System.out.println("环绕通知-返回通知...");
} catch (Throwable throwable){
throwable.printStackTrace();
System.out.println("环绕通知-异常通知...");
}
System.out.println("环绕通知-最终通知...");

return object;
}



}
xml文件配置方式
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
32
33
34
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop.xsd">

<!-- 开启自动化扫描 -->
<context:component-scan base-package="com.xxxx"/>

<!-- aop 相关配置 -->
<aop:config>
<!-- aop 切面 -->
<aop:aspect ref="logCut02">
<!-- 定义 aop 切入点 -->
<aop:pointcut id="cut" expression="execution(* com.xxxx.service..*.*(..))"/>
<!-- 配置前置通知,设置前置通知对应的方法名及切入点 -->
<aop:before method="before" pointcut-ref="cut"/>
<!-- 配置返回通知,设置返回通知对应的方法名及切入点 -->
<aop:after-returning method="afterReturn" pointcut-ref="cut"/>
<!-- 配置最终通知,设置最终通知对应的方法名及切入点 -->
<aop:after method="after" pointcut-ref="cut"/>
<!-- 配置异常通知,设置异常通知对应的方法名及切入点 -->
<aop:after-throwing method="afterThrow" pointcut-ref="cut" throwing="e" />
<!-- 配置环绕通知,设置环绕通知对应的方法名及切入点 -->
<aop:around method="around" pointcut-ref="cut"/>
</aop:aspect>
</aop:config>

</beans>

源代码 https://github.com/Breeze1203/JavaAdvanced/tree/main/springboot-demo/spring-aop-demo

SpringBoot中的@Configuration注解

  1. 标识配置类:使用 @Configuration 注解的类被标识为配置类,告诉 Spring 这个类是用来配置 Spring 应用程序上下文的。

  2. 定义 Bean:在 @Configuration 注解的类中,我们可以使用 @Bean 注解定义 Bean 对象,Spring 容器会自动扫描这些 Bean,并将它们纳入管理。

  3. 代替 XML 配置:通过使用注解配置类,可以避免传统 XML 配置文件的烦琐,使得配置更加简洁和易于维护。

  4. 组件扫描@Configuration 注解会隐式地向 Spring 容器注册一个 Bean,类型为该配置类本身,从而使 Spring 可以基于组件扫描找到其他 Bean。

  5. 配合其他注解使用@Configuration 注解通常与其他注解一起使用,比如 @Bean@ComponentScan@Import 等,来实现复杂配置或者注入相关 Bean

当一个类被该注解标记后,被标记为配置类,在里面定义的Bean对象会被纳入spring容器,交由容器管理

1
2
3
4
5
6
7
8
9
@Configuration

public class MainConfig {

@Bean(name = "MainConfig")
public MyBean myBean(){
return new MyBean("MainConfig");
}
}

当我们启动容器后,name为MainConfig的这个bean会被自定注入到spring容器,我们可以获取到;

1
2
3
ConfigurableApplicationContext run = SpringApplication.run(SpringbootStudyApplication.class, args);
Object mainConfig = run.getBean("MainConfig");
System.out.println(mainConfig.toString());

结果

导入额外的 Configuration 类,@Import注解

我们先定义**一个**MyBean类型bean,这个方法不被@C Configuration标记,可以是不是会被自动纳入容器;

启动容器,获取这个名称的bean

运行结果

可以看到这个bean没有被加入到spring容器

当我们在一个被@Configuration标记的类上面使用@import注解引入这个名为AdditionalConfig1的bean,如下

1
2
3
4
5
6
7
8
9
@Configuration
@Import(AdditionalConfig1.class)
public class MainConfig {

@Bean(name = "MainConfig")
public MyBean myBean(){
return new MyBean("MainConfig");
}
}

再次启动容器查看

可以看到,容器成功捕获到

@Import注解通常用于引入其他Java配置类,而不是XML文件。如果你想引入XML配置文件中的bean定义,通常会使用@ImportResource注解

假设你在resources下有一bean.xml文件,内容如下

通过@ImportResource导入xml文件里面这个bean

启动容器

可以看到这个bean也被引入进来

什么是session(会话)?

会话是一种持久的网络协议,用于完成服务器和客户端之间的一些交互行为。会话是一个比连接粒度更大的概念,一次会话可能包含多次连接,每次连接都被认为是会话的一次操作。

在Web中,Session是指一个用户与网站服务器进行一系列交互的持续时间,通常指从注册进入系统到注销退出系统之间所经过的时间,以及在这段时间内进行的操作,还有,服务器端为保存用户状态开辟的存储空间。

http协议的无状态性

每条http请求/响应是互相独立的,服务器并不知道两条http请求是同一用户发送的还是不同的用户发送的,就好比生活中常见的饮料自动贩售机,投入硬币,选择饮料,然后饮料出来,买饮料的人拿到饮料,整个过程中贩售机只负责识别要买的是哪种饮料并且给出饮料,并没有记录是哪个人买的,以及某个人买了哪几种,每种买了几瓶。如果考虑现在的购物网站的购物车功能,记录用户状态就很有必要了,这就需要用到会话,而http协议由于本身的无状态性就需要cookie来实现会话。

cookie的概念

用户第一次访问网站时,服务器对用户没有任何了解,于是通过http响应给用户发送一个cookie,让浏览器存下来,浏览器记住这个cookie之后,用户再向这个网站发送http请求的时候就带上这个cookie,服务器收到这个cookie后就能识别出这个特定的用户,从而通过查询服务器数据库中为这个用户积累的一些特定信息来给用户一些个性化服务

Session和cookie在基于express框架项目中的应用

import session from "express-session";
import mongo from "connect-mongo"; // 一般用来将session存储到数据库中

const MongoStore = mongo(session);
app.use(session({
    resave: true,// 强制保存session 
    saveUninitialized: true,// 强制保存未初始化内容
    secret: SESSION_SECRET, // 加密字符串,防止篡改cookie
    store: new MongoStore({ //将session存进数据库 
        url: MONGODB_URI,
        autoReconnect: true
    })
}));

上述代码中的cookie项即是用来存储sessionID的,在express-session这个中间件中,存储sessionID的cookie的名字是connet.sid。

访问这个项目的页面,在浏览器控制台中可以看到response header部分的set-Cookie

当登录网站的时候,服务端就创建了一个session,把sessionID存在connect.sid这一cookie值中,然后通过Set-Cookie这个响应头把sessionID发回给浏览器,接着刷新页面就可以看到请求的request header部分

也就是说,浏览器通过cookie把这个sessionID存了下来,又通过这个http请求把刚才收到的cookie值也就是sessionID发回给了服务器,服务器就能识别出这个http请求还是同一个用户的会话。

创建了会话之后,就可以通过req.session 获取当前用户的会话对象(这个对象是存在服务端数据库中的),比如这个项目中,用户注册或者登陆以后就把user对象存到req.session.user中,登出时则req.session.user = null;,从而可以通过req.session.user判断用户的登录状态,控制用户访问页面的权限。

总结

http协议只完成请求和响应的工作,不能记录用户状态,服务器为了记录用户状态,跟踪用户行为,从而提供个性化服务而引入session,用户的一系列交互行为都包含在session内,用户状态信息也存储在服务器端为session开辟的一个存储空间内;当用户通过浏览器发送一系列http请求时,为了识别这些请求属于哪个session,服务端需要给每个session一个sessionID,cookie就是浏览器端用来存储和发送这个sessionID的

配置并发会话控制

如果你希望对单个用户登录你的应用程序的能力进行限制, Spring Security 支持开箱即用,只需添加以下简单内容。首先,你需要在你的配置中添加以下 listener,以保持  Spring Security 对会话生命周期事件的更新

1
2
3
4
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}

然后在你的 security 配置中添加以下几行:

1
2
3
4
5
6
7
8
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement(session -> session
.maximumSessions(1)
);
return http.build();
}

Copied

检测超时

会话会自行过期,不需要做任何事情来确保 security context 被删除。也就是说, Spring Security 可以检测到会话过期的情况,并采取你指定的具体行动。例如,当用户用已经过期的会话发出请求时,你可能想重定向到一个特定的端点。这可以通过 HttpSecurity 中的 invalidSessionUrl 实现

定制失效会话的策略

invalidSessionUrl 是使用 SimpleRedirectInvalidSessionStrategy 实现 来设置 InvalidSessionStrategy 的方便方法。如果你想自定义行为,你可以实现 InvalidSessionStrategy 接口并使用 invalidSessionStrategy 方法进行配置

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
@Component
public class CustomizeInvalidSessionStrategy implements InvalidSessionStrategy {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

@Override
public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
// 设置响应的内容类型和字符编码
response.setContentType("text/html;charset=UTF-8");
// 输出会话已过期的提示信息到浏览器
String id = request.getSession().getId();
// 删除对应用户认证的信息
redisTemplate.delete(CustomizeSecurityContextRepository.SECURITY_CONTEXT_KEY+id);
// 删除对应的cookie
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals("JSESSIONID")) {
cookie.setMaxAge(0); // 设置cookie的过期时间为0,浏览器会将其删除
response.addCookie(cookie);
break;
}
}
}
response.getWriter().write("会话已过期,请重新登录");
}
}

当你想要开始一个 Vue 3 项目时,下面是一份完整的搭建流程:

1. 创建项目

首先,你需要安装 Vue CLI 工具。如果你还没有安装,你可以通过以下命令进行安装:

npm install -g @vue/cli

然后,你可以使用 Vue CLI 创建一个新的 Vue 3 项目。在命令行中运行:

vue create my-vue3-project

然后按照提示进行选择,选择 Vue 3 作为你的项目模板。

2. 安装依赖

进入到你的项目目录,并安装其他依赖项。通常情况下,你需要安装 Vue Router 和 Vuex(如果需要):

cd my-vue3-project

npm install vue-router@next vuex@next

3. 创建路由器和状态管理器(可选)

如果你的项目需要使用路由和状态管理,你可以创建路由器和状态管理器。在 src 目录下创建 routerstore 文件夹,并分别创建 index.js 文件来配置路由器和状态管理器。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// router/index.js

import { createRouter, createWebHistory } from 'vue-router';

import Home from '../views/Home.vue';

const routes = [

{ path: '/', component: Home }

// 其他路由配置

];

const router = createRouter({

history: createWebHistory(),

routes

});

export default router;


// store/index.js

import { createStore } from 'vuex';

const store = createStore({

state() {

return {

// 状态管理的状态

};

},

mutations: {

// 状态管理的变化

},

actions: {

// 异步操作

},

getters: {

// 获取状态

}

});

export default store;

4.集成Element UI

# 选择一个你喜欢的包管理器

# NPM
$ npm install element-plus --save

# Yarn
$ yarn add element-plus

# pnpm
$ pnpm install element-plus
完整引入

如果你对打包后的文件大小不是很在乎,那么使用完整导入会更方便。

// main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'

const app = createApp(App)

app.use(ElementPlus)
app.mount('#app')
Volar 支持

如果您使用 Volar,请在 tsconfig.json 中通过 compilerOptions.type 指定全局组件类型。

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "types": ["element-plus/global"]
  }
}
按需导入

您需要使用额外的插件来导入要使用的组件。

自动导入推荐

首先你需要安装unplugin-vue-componentsunplugin-auto-import这两款插件

npm install -D unplugin-vue-components unplugin-auto-import

然后把下列代码插入到你的 ViteWebpack 的配置文件中

Vite
// vite.config.ts
import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  // ...
  plugins: [
    // ...
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
})
Webpack
// webpack.config.js
const AutoImport = require('unplugin-auto-import/webpack')
const Components = require('unplugin-vue-components/webpack')
const { ElementPlusResolver } = require('unplugin-vue-components/resolvers')

module.exports = {
  // ...
  plugins: [
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
}

想了解更多打包 (Rollup, Vue CLI) 和配置工具,请参考 unplugin-vue-componentsunplugin-auto-import

Nuxt

对于 Nuxt 用户,只需要安装 @element-plus/nuxt 即可。

npm install -D @element-plus/nuxt

然后将下面的代码写入你的配置文件.

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@element-plus/nuxt'],
})

配置文档参考 docs.

手动导入

Element Plus 提供了基于 ES Module 的开箱即用的 Tree Shaking 功能。

但你需要安装 unplugin-element-plus 来导入样式。 配置文档参考 docs.

App.vue

<template>
  <el-button>我是 ElButton</el-button>
</template>
<script>
  import { ElButton } from 'element-plus'
  export default {
    components: { ElButton },
  }
</script>

// vite.config.ts
import { defineConfig } from 'vite'
import ElementPlus from 'unplugin-element-plus/vite'

export default defineConfig({
  // ...
  plugins: [ElementPlus()],
})

5.集成Ant Design

传送门 https://ant.design/docs/react/use-with-vite-cn

6. 创建组件

开始编写你的 Vue 组件。在 src 目录下创建 components 文件夹,并在其中创建你的 Vue 组件。

7. 使用组件

在你的应用程序中使用你创建的组件。编辑 App.vue 文件,并在其中使用你的组件。

8. 编写样式

编写你的样式文件,可以使用 CSS、Sass、Less 等预处理器。确保将样式文件导入到你的组件中。

9. 启动项目

最后,运行你的 Vue 3 项目:

npm run serve

然后在浏览器中打开 http://localhost:8080/ 查看你的项目。

这就是一个 Vue 3 项目搭建的基本流程。根据你的项目需求,可能还需要进行其他配置和操作,但这个流程可以帮助你开始一个简单的 Vue 3 项目。

index.vue

<template>
 <div class="select-none">
  <img :src="bg" class="wave" />
  <div class="flex-c absolute right-5 top-3"></div>
  <div class="login-container">
   <div class="img">
    <img :src="illustration" />
   </div>
   <div class="login-box">
    <div class="login-form">
     <div class="login-title">{{ getThemeConfig.globalTitle }}</div>
     <el-tabs v-model="tabsActiveName">
      <!-- 用户名密码登录 -->
      <el-tab-pane :label="$t('label.one1')" name="account">
       <Password @signInSuccess="signInSuccess" />
      </el-tab-pane>
      <!-- 手机号登录 -->
      <el-tab-pane :label="$t('label.two2')" name="mobile">
       <Mobile @signInSuccess="signInSuccess" />
      </el-tab-pane>
      <!-- 注册 -->
      <el-tab-pane :label="$t('label.register')" name="register" v-if="registerEnable">
       <Register @afterSuccess="tabsActiveName = 'account'" />
      </el-tab-pane>
     </el-tabs>
    </div>
   </div>
  </div>
 </div>
</template>

<script setup lang="ts" name="loginIndex">
import { useThemeConfig } from '/@/stores/themeConfig';
import { NextLoading } from '/@/utils/loading';
import illustration from '/@/assets/login/login_bg.svg';
import bg from '/@/assets/login/bg.png';
import { useI18n } from 'vue-i18n';
import { formatAxis } from '/@/utils/formatTime';
import { useMessage } from '/@/hooks/message';
import { Session } from '/@/utils/storage';
import { initBackEndControlRoutes } from '/@/router/backEnd';

// 引入组件
const Password = defineAsyncComponent(() => import('./component/password.vue'));
const Mobile = defineAsyncComponent(() => import('./component/mobile.vue'));
const Register = defineAsyncComponent(() => import('./component/register.vue'));

// 定义变量内容
const storesThemeConfig = useThemeConfig();
const { themeConfig } = storeToRefs(storesThemeConfig);
const { t } = useI18n();
const route = useRoute();
const router = useRouter();

// 是否开启注册
const registerEnable = ref(import.meta.env.VITE_REGISTER_ENABLE === 'true');

// 默认选择账号密码登录方式
const tabsActiveName = ref('account');

// 获取布局配置信息
const getThemeConfig = computed(() => {
 return themeConfig.value;
});

// 登录成功后的跳转处理事件
const signInSuccess = async () => {
 const isNoPower = await initBackEndControlRoutes();
 if (isNoPower) {
  useMessage().wraning('抱歉,您没有登录权限');
  Session.clear();
 } else {
  // 初始化登录成功时间问候语
  let currentTimeInfo = formatAxis(new Date());
  if (route.query?.redirect) {
   router.push({
    path: <string>route.query?.redirect,
    query: Object.keys(<string>route.query?.params).length > 0 ? JSON.parse(<string>route.query?.params) : '',
   });
  } else {
   router.push('/');
  }
  // 登录成功提示
  const signInText = t('signInText');
  useMessage().success(`${currentTimeInfo},${signInText}`);
  // 添加 loading,防止第一次进入界面时出现短暂空白
  NextLoading.start();
 }
};

// 页面加载时
onMounted(() => {
 NextLoading.done();
});
</script>

register.vue

<template>
 <el-form size="large" class="login-content-form" :rules="dataRules" ref="dataFormRef" :model="state.ruleForm">
  <el-form-item class="login-animation1" prop="username">
   <el-input text :placeholder="$t('password.accountPlaceholder1')" v-model="state.ruleForm.username" clearable autocomplete="off">
    <template #prefix>
     <el-icon class="el-input__icon">
      <ele-User />
     </el-icon>
    </template>
   </el-input>
  </el-form-item>
  <el-form-item class="login-animation2" prop="password">
   <strength-meter
    :placeholder="$t('password.accountPlaceholder2')"
    v-model="state.ruleForm.password"
    autocomplete="off"
    :maxLength="20"
    :minLength="6"
    @score="handlePassScore"
    ><template #prefix>
     <el-icon class="el-input__icon">
      <ele-Unlock />
     </el-icon>
    </template>
   </strength-meter>
  </el-form-item>
  <el-form-item class="login-animation3" prop="phone">
   <el-input text :placeholder="$t('password.phonePlaceholder4')" v-model="state.ruleForm.phone" clearable autocomplete="off">
    <template #prefix>
     <el-icon class="el-input__icon">
      <ele-Position />
     </el-icon>
    </template>
   </el-input>
  </el-form-item>
  <el-form-item>
   <el-checkbox v-model="state.ruleForm.checked">
    {{ $t('password.readAccept') }}
   </el-checkbox>
   <el-button link type="primary">
    {{ $t('password.privacyPolicy') }}
   </el-button>
  </el-form-item>
  <el-form-item class="login-animation4">
   <el-button type="primary" class="login-content-submit" v-waves @click="handleRegister" :loading="loading">
    <span>{{ $t('password.registerBtnText') }}</span>
   </el-button>
  </el-form-item>
 </el-form>
</template>

<script setup lang="ts" name="register">
import { registerUser, validateUsername, validatePhone } from '/@/api/admin/user';
import { useMessage } from '/@/hooks/message';
import { useI18n } from 'vue-i18n';
import { rule } from '/@/utils/validate';

// 注册生命周期事件
const emit = defineEmits(['afterSuccess']);

// 按需加载组件
const StrengthMeter = defineAsyncComponent(() => import('/@/components/StrengthMeter/index.vue'));

// 使用i18n
const { t } = useI18n();

// 表单引用
const dataFormRef = ref();

// 加载中状态
const loading = ref(false);

// 密码强度得分
const score = ref('0');

// 组件内部状态
const state = reactive({
 // 是否显示密码
 isShowPassword: false,
 // 表单内容
 ruleForm: {
  username: '', // 用户名
  password: '', // 密码
  phone: '', // 手机号
  checked: '', // 是否同意条款
 },
});

// 表单验证规则
const dataRules = reactive({
 username: [
  { required: true, message: '用户名不能为空', trigger: 'blur' },
  {
   min: 5,
   max: 20,
   message: '用户名称长度必须介于 5 和 20 之间',
   trigger: 'blur',
  },
  // 自定义方法验证用户名
  {
   validator: (rule, value, callback) => {
    validateUsername(rule, value, callback, false);
   },
   trigger: 'blur',
  },
 ],
 phone: [
  { required: true, message: '手机号不能为空', trigger: 'blur' },
  // 手机号格式验证方法
  {
   validator: rule.validatePhone,
   trigger: 'blur',
  },
  // 自定义方法验证手机号是否重复
  {
   validator: (rule, value, callback) => {
    validatePhone(rule, value, callback, false);
   },
   trigger: 'blur',
  },
 ],
 password: [
  { required: true, message: '密码不能为空', trigger: 'blur' },
  {
   min: 6,
   max: 20,
   message: '用户密码长度必须介于 6 和 20 之间',
   trigger: 'blur',
  },
  // 判断密码强度是否达到要求
  {
   validator: (_rule, _value, callback) => {
    if (Number(score.value) < 2) {
     callback('密码强度太低');
    } else {
     callback();
    }
   },
   trigger: 'blur',
  },
 ],
 checked: [{ required: true, message: '请阅读并同意条款', trigger: 'blur' }],
});

// 处理密码强度得分变化事件
const handlePassScore = (e) => {
 score.value = e;
};

/**
 * @name handleRegister
 * @description 注册事件,包括表单验证、注册、成功后的钩子函数触发
 */
const handleRegister = async () => {
 // 验证表单是否符合规则
 const valid = await dataFormRef.value.validate().catch(() => {});
 if (!valid) return false;

 try {
  // 开始加载
  loading.value = true;
  // 调用注册API
  await registerUser(state.ruleForm);
  // 注册成功提示
  useMessage().success(t('common.optSuccessText'));
  // 触发注册成功后的钩子函数
  emit('afterSuccess');
 } catch (err: any) {
  // 提示错误信息
  useMessage().error(err.msg);
 } finally {
  // 结束加载状态
  loading.value = false;
 }
};
</script>

password.vue

<template>
  <el-form size="large" class="login-content-form" ref="loginFormRef" :rules="loginRules" :model="state.ruleForm"
           @keyup.enter="onSignIn">
    <el-form-item class="login-animation1" prop="username">
      <el-input text :placeholder="$t('password.accountPlaceholder1')" v-model="state.ruleForm.username" clearable
                autocomplete="off">
        <template #prefix>
          <el-icon class="el-input__icon">
            <ele-User/>
          </el-icon>
        </template>
      </el-input>
    </el-form-item>
    <el-form-item class="login-animation2" prop="password">
      <el-input
          :type="state.isShowPassword ? 'text' : 'password'"
          :placeholder="$t('password.accountPlaceholder2')"
          v-model="state.ruleForm.password"
          autocomplete="off"
      >
        <template #prefix>
          <el-icon class="el-input__icon">
            <ele-Unlock/>
          </el-icon>
        </template>
        <template #suffix>
          <i
              class="iconfont el-input__icon login-content-password"
              :class="state.isShowPassword ? 'icon-yincangmima' : 'icon-xianshimima'"
              @click="state.isShowPassword = !state.isShowPassword"
          >
          </i>
        </template>
      </el-input>
    </el-form-item>
    <el-form-item class="login-animation2" prop="code" v-if="verifyEnable">
      <el-col :span="15">
        <el-input text maxlength="4" :placeholder="$t('mobile.placeholder2')" v-model="state.ruleForm.code" clearable
                  autocomplete="off">
          <template #prefix>
            <el-icon class="el-input__icon">
              <ele-Position/>
            </el-icon>
          </template>
        </el-input>
      </el-col>
      <el-col :span="1"></el-col>
      <el-col :span="8">
        <img :src="imgSrc" @click="getVerifyCode">
      </el-col>
    </el-form-item>
    <el-form-item class="login-animation4">
      <el-button type="primary" class="login-content-submit" :loading="loading" @click="onSignIn">
        <span>{{ $t('password.accountBtnText') }}</span>
      </el-button>
    </el-form-item>
    <div class="font12 mt30 login-animation4 login-msg">{{ $t('browserMsgText') }}</div>
  </el-form>
</template>

<script setup lang="ts" name="password">
import {reactive, ref, defineEmits} from 'vue';
import {useUserInfo} from '/@/stores/userInfo';
import {useI18n} from 'vue-i18n';
import {generateUUID} from "/@/utils/other";

// 使用国际化插件
const {t} = useI18n();

// 定义变量内容
const emit = defineEmits(['signInSuccess']); // 声明事件名称
const loginFormRef = ref(); // 定义LoginForm表单引用
const loading = ref(false); // 定义是否正在登录中
const state = reactive({
  isShowPassword: false, // 是否显示密码
  ruleForm: {
    // 表单数据
    username: 'admin', // 用户名
    password: '123456', // 密码
    code: '', // 验证码
    randomStr: '', // 验证码随机数
  },
});

const loginRules = reactive({
  username: [{required: true, trigger: 'blur', message: t('password.accountPlaceholder1')}], // 用户名校验规则
  password: [{required: true, trigger: 'blur', message: t('password.accountPlaceholder2')}], // 密码校验规则
  code: [{required: true, trigger: 'blur', message: t('password.accountPlaceholder3')}], // 验证码校验规则
});

// 是否开启验证码
const verifyEnable = ref(import.meta.env.VITE_VERIFY_ENABLE === 'true');
const imgSrc = ref('')

//获取验证码图片
const getVerifyCode = () => {
  state.ruleForm.randomStr = generateUUID()
  imgSrc.value = `${import.meta.env.VITE_API_URL}${import.meta.env.VITE_IS_MICRO == 'false' ? '/admin' : '/auth'}/code/image?randomStr=${state.ruleForm.randomStr}`
}

// 账号密码登录
const onSignIn = async () => {
  const valid = await loginFormRef.value.validate().catch(() => {}); // 表单校验
  if (!valid) return false;

  loading.value = true; // 正在登录中
  try {
    await useUserInfo().login(state.ruleForm); // 调用登录方法
    emit('signInSuccess'); // 触发事件
  } finally {
    getVerifyCode()
    loading.value = false; // 登录结束
  }
};

onMounted(() => {
  getVerifyCode()
})

</script>

mobail.vue

<template>
 <el-form size="large" class="login-content-form" ref="loginFormRef" :rules="loginRules" :model="loginForm" @keyup.enter="handleLogin">
  <el-form-item class="login-animation1" prop="mobile">
   <el-input text :placeholder="$t('mobile.placeholder1')" v-model="loginForm.mobile" clearable autocomplete="off">
    <template #prefix>
     <i class="iconfont icon-dianhua el-input__icon"></i>
    </template>
   </el-input>
  </el-form-item>
  <el-form-item class="login-animation2" prop="code">
   <el-col :span="15">
    <el-input text maxlength="6" :placeholder="$t('mobile.placeholder2')" v-model="loginForm.code" clearable autocomplete="off">
     <template #prefix>
      <el-icon class="el-input__icon">
       <ele-Position />
      </el-icon>
     </template>
    </el-input>
   </el-col>
   <el-col :span="1"></el-col>
   <el-col :span="8">
    <el-button v-waves class="login-content-code" @click="handleSendCode" :loading="msg.msgKey">{{ msg.msgText }} </el-button>
   </el-col>
  </el-form-item>
  <el-form-item class="login-animation3">
   <el-button type="primary" v-waves class="login-content-submit" @click="handleLogin" :loading="loading">
    <span>{{ $t('mobile.btnText') }}</span>
   </el-button>
  </el-form-item>
 </el-form>
</template>

<script setup lang="ts" name="loginMobile">
import { sendMobileCode } from '/@/api/login';
import { useMessage } from '/@/hooks/message';
import { useUserInfo } from '/@/stores/userInfo';
import { rule } from '/@/utils/validate';
import { useI18n } from 'vue-i18n';

const { t } = useI18n();
const emit = defineEmits(['signInSuccess']);

// 创建一个 ref 对象,并将其初始化为 null
const loginFormRef = ref();
const loading = ref(false);

// 定义响应式对象
const loginForm = reactive({
 mobile: '',
 code: '',
});

// 定义校验规则
const loginRules = reactive({
 mobile: [{ required: true, trigger: 'blur', validator: rule.validatePhone }],
 code: [
  {
   required: true,
   trigger: 'blur',
   message: t('mobile.codeText'),
  },
 ],
});

/**
 * 处理发送验证码事件。
 */
const handleSendCode = async () => {
 const valid = await loginFormRef.value.validateField('mobile').catch(() => {});
 if (!valid) return;

 const response = await sendMobileCode(loginForm.mobile);
 if (response.data) {
  useMessage().success('验证码发送成功');
  timeCacl();
 } else {
  useMessage().error(response.msg);
 }
};

/**
 * 处理登录事件。
 */
const handleLogin = async () => {
 const valid = await loginFormRef.value.validate().catch(() => {});
 if (!valid) return;

 try {
  loading.value = true;
  await useUserInfo().loginByMobile(loginForm);
  emit('signInSuccess');
 } finally {
  loading.value = false;
 }
};

// 定义响应式对象
const msg = reactive({
 msgText: t('mobile.codeText'),
 msgTime: 60,
 msgKey: false,
});

/**
 * 计算并更新倒计时。
 */
const timeCacl = () => {
 msg.msgText = `${msg.msgTime}秒后重发`;
 msg.msgKey = true;
 const time = setInterval(() => {
  msg.msgTime--;
  msg.msgText = `${msg.msgTime}秒后重发`;
  if (msg.msgTime === 0) {
   msg.msgTime = 60;
   msg.msgText = t('mobile.codeText');
   msg.msgKey = false;
   clearInterval(time);
  }
 }, 1000);
};
</script>

设计模式

单例模式是一种创建型设计模式, 让你能够保证一个类只有一个实例, 并提供一个访问该实例的全局节点

保证一个类只有一个实例为什么会有人想要控制一个类所拥有的实例数量最常见的原因是控制某些共享资源例如数据库或文件的访问权限它的运作方式是这样的如果你创建了一个对象同时过一会儿后你决定再创建一个新对象此时你会获得之前已创建的对象而不是一个新对象注意普通构造函数无法实现上述行为因为构造函数的设计决定了它必须总是返回一个新对象

为该实例提供一个全局访问节点还记得你好吧其实是我自己用过的那些存储重要对象的全局变量吗它们在使用上十分方便但同时也非常不安全因为任何代码都有可能覆盖掉那些变量的内容从而引发程序崩溃和全局变量一样单例模式也允许在程序的任何地方访问特定对象但是它可以保护该实例不被其他代码覆盖还有一点你不会希望解决同一个问题的代码分散在程序各处的因此更好的方式是将其放在同一个类中特别是当其他代码已经依赖这个类时更应该如此

实现步骤:

所有单例的实现都包含以下两个相同的步骤

  • 将默认构造函数设为私有防止其他对象使用单例类的 new运算符

  • 新建一个静态构建方法作为构造函数该函数会偷偷调用私有构造函数来创建对象并将其保存在一个静态成员变量中此后所有对于该函数的调用都将返回这一缓存对象

如果你的代码能够访问单例类那它就能调用单例类的静态方法无论何时调用该方法它总是会返回相同的对象

伪代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Singleton {
private static Singleton instance;
public String value;

private Singleton(String value) {
this.value = value;
}

public static Singleton getInstance(String value) {
if (instance == null) {
instance = new Singleton(value);
}
return instance;
}
}
单例模式优缺点
优点:
  1. 你可以保证一个类只有一个实例

  2. 你获得了一个指向该实例的全局访问节点

  3. 仅在首次请求单例对象时对其进行初始化

缺点:
  1. 违反了单一职责原则, 该模式同时解决了两个问题

  2. 单例模式可能掩盖不良设计, 比如程序各组件之间相互了解过多等

  3. 该模式在多线程环境下需要进行特殊处理, 避免多个线程多次创建单例对象

  4. 单例的客户端代码单元测试可能会比较困难, 因为许多测试框架以基于继承的方式创建模拟对象。 由于单例类的构造函数是私有的, 而且绝大部分语言无法重写静态方法, 所以你需要想出仔细考虑模拟单例的方法。 要么干脆不编写测试代码, 或者不使用单例模式

只保证创建一个对象,为什么还要避免多线程创建对象
  1. 重复创建对象:多个线程同时通过单例模式创建对象时,可能会导致多次实例化,破坏了单例模式的初衷。

  2. 状态不一致:如果单例对象的创建过程中涉及到一些状态的设置或初始化操作,多个线程同时进行创建可能会导致状态不一致,使得对象处于不可预测的状态。

  3. 资源竞争:如果单例对象的创建涉及到一些共享资源或者文件操作等,多个线程同时创建可能会导致资源竞争和冲突,进而造成程序崩溃或数据损坏等问题

对于第一点可以考虑以下情况:

  1. 线程 A 和线程 B 同时检查到实例对象不存在。

  2. 线程 A 先获取到锁,开始创建实例对象。

  3. 线程 A 创建完实例对象后释放锁。

  4. 线程 B 获取到锁,然后也创建实例对象。

  5. 这样就导致了两个实例对象的创建

这就是多线程环境下可能出现的问题。为了解决这个问题,需要在创建实例对象的过程中引入同步机制,确保只有一个线程可以执行创建操作。常用的方法包括使用双重检查锁、静态内部类等,这些方法可以有效地保证在多线程环境下只创建一个实例对象

单例模式在java中的应用场景
Spring Bean管理

在Spring框架中,当我们声明一个Bean时,默认情况下是单例的。这意味着Spring容器会管理Bean的生命周期,并确保在整个应用程序中只有一个实例

1
2
3
4
@Component
public class MySingletonBean {
// Class definition
}
线程池

在多线程环境下,线程池是一种常见的单例对象。它负责管理可用的线程,并提供给需要执行任务的线程

1
ExecutorService executorService=Executors.newFixedThreadPool(10);
日志对象

在Java应用中,通常会使用单例模式来管理日志记录器对象,以确保在整个应用程序中只有一个日志实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Logger {
private static Logger instance;

private Logger() {
// private constructor to prevent instantiation
}

public static Logger getInstance() {
if (instance == null) {
instance = new Logger();
}
return instance;
}

public void log(String message) {
// log message
}
}
数据库连接池

在使用数据库时,可以使用单例模式来管理数据库连接池,以确保在应用程序中只有一个连接池实例,避免资源浪费和性能问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DatabaseConnectionPool {
private static DatabaseConnectionPool instance;

private DatabaseConnectionPool() {
// private constructor to prevent instantiation
}

public static DatabaseConnectionPool getInstance() {
if (instance == null) {
instance = new DatabaseConnectionPool();
}
return instance;
}

// Other methods for managing database connections
}
0%