import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
// 路由守卫
let registerRouteFresh = true // 定义标识,记录路由是否添加
router.beforeEach(async (to, from, next) => {
  let res = [ // 路由数据,正常情况通过接口获取,这里我使用假数据代替
    {
      "id": 1000,
      "parentId": -1,
      "icon": "user",
      "name": "用户",
      "path": "/user",
      "routerName": 'user',
      "component": "views/user/User.vue",
      "redirect": '/user/set',
      "children": [{
          "id": 1100,
          "parentId": 1000,
          "icon": "use-set",
          "name": "用户管理",
          "routerName": 'userSet',
          "path": "/user/set",
          "component": "views/user/User.vue",
          "redirect": null
      }],
    }, 
    {
      "id": 2000,
      "parentId": -1,
      "icon": "test",
      "name": "测试",
      "path": "/test",
      "routerName": 'test',
      "component": "view/test/index",
      "redirect": '/test/user',
      "children": [{
          "id": 2100,
          "parentId": 2000,
          "icon": "test-user",
          "name": "用户测试",
          "routerName": 'testUser',
          "path": "/test/user",
          "component": "views/user.User.vue",
          "redirect": null
      }]
    },
  ]
  let arr = [] // 整理后台数据,转换为添加路由的格式
  res.filter((value) => {
    let child = []  // 子路由数据格式处理
    if (value.children && value.children.length) {
      value.children.filter((val) => {
        child.push({
          name: val.routeName,
          path: val.path,
          component: () => import(`@/${val.component}`) // 开发中遇到问题,不能使用纯变量,需要字符串拼接才可以,要不然同样的地址报错。
        })
      })
    }
    arr.push({
      name: value.routeName,
      redirect: value.redirect,
      path: value.path,
      component: () => import(`@/${value.component}`),
      children: child
    })
  })
  // 如果首次或者刷新界面,next(...to, replace: true)会循环遍历路由,如果to找不到对应的路由那么他会再执行一次beforeEach((to, from, next))直到找到对应的路由,
  // 我们的问题在于页面刷新以后异步获取数据,直接执行next()感觉路由添加了但是在next()之后执行的,所以我们没法导航到相应的界面。这里使用变量registerRouteFresh变量做记录,直到找到相应的路由以后,把值设置为false然后走else执行next(),整个流程就走完了,路由也就添加完了。
  if (registerRouteFresh) {
    arr.forEach((val) => {
      router.addRoute(val)
    })
    next({...to, replace: true})
    registerRouteFresh = false
  } else {
    next()
  }
})
createApp(App).use(store).use(router).mount('#app')

一口气了解OAuth

OAuth2.0为何物?

OAuth 简单理解就是一种授权机制,它是在客户端和资源所有者之间的授权层,用来分离两种不同的角色。在资源所有者同意并向客户端颁发令牌后,客户端携带令牌可以访问资源所有者的资源;

OAuth2.0OAuth 协议的一个版本,有2.0版本那就有1.0版本,有意思的是OAuth2.0 却不向下兼容OAuth1.0 ,相当于废弃了1.0版本

举个小例子解释一下什么是 OAuth 授权?

我定了一个外卖,外卖小哥30秒火速到达了我家楼下,奈何有门禁进不来,可以输入密码进入,但出于安全的考虑我并不想告诉他密码

此时外卖小哥看到门禁有一个高级按钮“一键获取授权”,只要我这边同意,他会获取到一个有效期 2小时的令牌(token)正常出入。

令牌(token)和 密码 的作用虽然相似都可以进入系统,但还有点不同。token 拥有权限范围,有时效性的,到期自动失效,而且无效修改

OAuth2授权模式

OAuth2.0 的授权简单理解其实就是获取令牌(token)的过程,OAuth 协议定义了四种获得令牌的授权方式(authorization grant )如下:

  • 授权码(authorization-code

  • 隐藏式(implicit

  • 密码式(password):

  • 客户端凭证(client credentials

但值得注意的是,不管我们使用哪一种授权方式,在三方应用申请令牌之前,都必须在系统中去申请身份唯一标识:客户端 ID(client ID)和 客户端密钥(client secret)。这样做可以保证 token 不被恶意使用。

下面我们会分析每种授权方式的原理,在进入正题前,先了解 OAuth2.0 授权过程中几个重要的参数:

  • response_type:code 表示要求返回授权码,token 表示直接返回令牌

  • client_id:客户端身份标识

  • client_secret:客户端密钥

  • redirect_uri:重定向地址

  • scope:表示授权的范围,read只读权限,all读写权限

  • grant_type:表示授权的方式,AUTHORIZATION_CODE(授权码)、password(密码)、client_credentials(凭证式)、refresh_token 更新令牌

  • state:应用程序传递的一个随机数,用来防止CSRF攻击

授权码

OAuth2.0四种授权中授权码方式是最为复杂,但也是安全系数最高的,比较常用的一种方式。这种方式适用于兼具前后端的Web项目,因为有些项目只有后端或只有前端,并不适用授权码模式。

下图我们以用WX登录掘金为例,详细看一下授权码方式的整体流程

用户选择WX登录掘金,掘金会向WX发起授权请求,接下来 WX询问用户是否同意授权(常见的弹窗授权)。response_typecode 要求返回授权码,scope 参数表示本次授权范围为只读权限,redirect_uri 重定向地址

1
2
3
4
5
https://wx.com/oauth/authorize?
response_type=code&
client_id=CLIENT_ID&
redirect_uri=http://juejin.im/callback&
scope=read

用户同意授权后,WX 根据 redirect_uri重定向并带上授权码

1
http://juejin.im/callback?code=AUTHORIZATION_CODE

当掘金拿到授权码(code)时,带授权码和密匙等参数向WX申请令牌。grant_type表示本次授权为授权码方式 authorization_code ,获取令牌要带上客户端密匙 client_secret,和上一步得到的授权码 code

https://wx.com/oauth/token?
 client_id=CLIENT_ID&
 client_secret=CLIENT_SECRET&
 grant_type=authorization_code&
 code=AUTHORIZATION_CODE&
 redirect_uri=http://juejin.im/callback

最后 WX 收到请求后向 redirect_uri 地址发送 JSON 数据,其中的access_token 就是令牌

 {    
  "access_token":"ACCESS_TOKEN",
  "token_type":"bearer",
  "expires_in":2592000,
  "refresh_token":"REFRESH_TOKEN",
  "scope":"read",
  ......
}
隐藏式

上边提到有一些Web应用是没有后端的, 属于纯前端应用,无法用上边的授权码模式。令牌的申请与存储都需要在前端完成,跳过了授权码这一步。

前端应用直接获取 tokenresponse_type 设置为 token,要求直接返回令牌,跳过授权码,WX授权通过后重定向到指定 redirect_uri

https://wx.com/oauth/authorize?
  response_type=token&
  client_id=CLIENT_ID&
  redirect_uri=http://juejin.im/callback&
  scope=read
密码式
https://wx.com/token?
  grant_type=password&
  username=USERNAME&
  password=PASSWORD&
  client_id=CLIENT_ID

这种授权方式缺点是显而易见的,非常的危险,如果采取此方式授权,该应用一定是可以高度信任的。

凭证式

凭证式和密码式很相似,主要适用于那些没有前端的命令行应用,可以用最简单的方式获取令牌,在请求响应的 JSON 结果中返回 token

grant_typeclient_credentials 表示凭证式授权,client_idclient_secret 用来识别身份。

https://wx.com/token?
  grant_type=client_credentials&
  client_id=CLIENT_ID&
  client_secret=CLIENT_SECRET

参考文章:https://mp.weixin.qq.com/s/in_E1pKqQc8wkPXT61g8gQ

Oauth认证过程演练:https://www.oauth.com/playground/

springboot动态配置数据源

简介:

项目开发中经常会遇到多数据源同时使用的场景,比如冷热数据的查询等情况,我们可以使用类似现成的工具包来解决问题,但在多数据源的使用中通常伴随着定制化的业务,所以一般的公司还是会自行实现多数据源切换的功能,接下来一起使用实现自定义注解的形式来实现一下

环境配置:
pom依赖导入
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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>spring-boot-dynamic-datasource</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-boot-dynamic-datasource</name>
<description>spring-boot-dynamic-datasource</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter-test</artifactId>
<version>3.0.3</version>
<scope>test</scope>
</dependency>
<!--连接池-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</build>

</project>
yml文件配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
master:
url: jdbc:mysql://localhost:3306/security?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password:
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
url: jdbc:mysql://localhost:3306/springsecurity?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password:
driver-class-name: com.mysql.cj.jdbc.Driver

在这里可以看到设置了两个数据库,一个是security,一个是springsecurity

在这两个数据库里面创建两个相同的表结构Student,security数据库sql脚本如下;

1
2
3
4
5
6
7
8
9
10
create table student
(
name varchar(15) null,
email varchar(35) null,
address varchar(15) null,
age int null,
id int null
);

INSERT INTO security.student (name, email, address, age, id) VALUES ('master', '3548297839@qq.com', '中国深圳', 18, null);

spring security数据库sql脚本如下;

1
2
3
4
5
6
7
8
9
10
create table student
(
name varchar(15) null,
email varchar(35) null,
address varchar(15) null,
age int null,
id int null
);

INSERT INTO security.student (name, email, address, age, id) VALUES ('slave', '3548297839@qq.com', '中国深圳', 18, null);

mybatis-plus配置不做赘述,提供一个查询所以student的方法;

管理数据源:

我们应用ThreadLocal来管理数据源信息,通过其中内容的get,set,remove方法来获取、设置、删除当前线程对应的数据源,创建一个DataSourceContextHolder类

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
package com.example.springbootdynamic.config;

public class DataSourceContextHolder {
private static final ThreadLocal<String> DATASOURCE_HOLDER = new ThreadLocal<>();

/**
* 获取当前线程的数据源
*
* @return 数据源名称
*/
public static String getDataSource() {
return DATASOURCE_HOLDER.get();
}

/**
* 设置数据源
*
* @param dataSourceName 数据源名称
*/
public static void setDataSource(String dataSourceName) {
DATASOURCE_HOLDER.set(dataSourceName);
}

/**
* 删除当前数据源
*/
public static void removeDataSource() {
DATASOURCE_HOLDER.remove();
}
}
重置数据源:

创建 DynamicDataSource 类并继AbstractRoutingDataSource,这样我们就可以重置当前的数据库路由,实现切换成想要执行的目标数据库

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
package com.example.springbootdynamic.config;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

import javax.sql.DataSource;
import java.util.Map;

public class DynamicDataSource extends AbstractRoutingDataSource {
public DynamicDataSource(DataSource defaultDataSource, Map<Object, Object> targetDataSources) {
/*
通过调用父类的方法 setDefaultTargetDataSource和
setTargetDataSources 来设置默认数据源和目标数据源映射关系
*/
super.setDefaultTargetDataSource(defaultDataSource);
super.setTargetDataSources(targetDataSources);
}

/**
* 这一步是关键,获取注册的数据源信息
* @return
* 实现了动态数据源的功能,根据某个上下文中的数据源标识动态地选择目标数据源
*/
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSource();
}
}
注册多个数据源:
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
package com.example.springbootdynamic.config;

import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class DateSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.druid.master")
public DataSource dynamicDatasourceMaster() {
return DruidDataSourceBuilder.create().build();
}

@Bean
@ConfigurationProperties(prefix = "spring.datasource.druid.slave")
public DataSource dynamicDatasourceSlave() {
return DruidDataSourceBuilder.create().build();
}

/*
通常用于标识一个Bean定义为首选的候选项。当存在多个相同类型的Bean时,
Spring容器会选择具有@Primary注解的Bean作为首选项
*/
@Bean(name = "dynamicDataSource")
@Primary
public DynamicDataSource createDynamicDataSource() {
Map<Object, Object> dataSourceMap = new HashMap<>();
// 设置默认的数据源为Master
DataSource defaultDataSource = dynamicDatasourceMaster();
dataSourceMap.put("master", defaultDataSource);
dataSourceMap.put("slave", dynamicDatasourceSlave());
return new DynamicDataSource(defaultDataSource, dataSourceMap);
}

}
启动类配置:

在启动类的@SpringBootApplication注解中排除DataSourceAutoConfiguration,否则会报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.springbootdynamic;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@MapperScan(basePackages = {"com.example.springbootdynamic.dao"})
public class SpringBootSpringBootDynamicApplication {

public static void main(String[] args) {
SpringApplication.run(SpringBootAffairsApplication.class, args);
}
}
启动项目手动切换数据源测试:

这里我准备了一个接口来验证,传入的 datasourceName 参数值就是刚刚注册的数据源的key

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
package com.example.springbootdynamic.controller;

import com.example.springbootdynamic.config.DataSourceContextHolder;
import com.example.springbootdynamic.entity.Student;
import com.example.springbootdynamic.service.impl.StudentServiceImpl;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class DynamicSwitchController {
@Resource
private StudentServiceImpl studentService;

@GetMapping("/switchDataSource/{datasourceName}")
public String switchDataSource(@PathVariable("datasourceName") String datasourceName) {
DataSourceContextHolder.setDataSource(datasourceName);
List<Student> allStudent = studentService.getAllStudent();
DataSourceContextHolder.removeDataSource();
return allStudent.toString();
}
}
测试结果:

当我们路径是master查询的student结果是master

当我们路径是slave查询的student结果是salve

至此通过执行结果,我们看到传递不同的数据源名称,已经实现了查询对应的数据库数据

注解实现切换数据源:

上边已经成功实现了手动切换数据源,但这种方式顶多算是半自动,我们每次都要通过传入参数来实现数据源的切换,我们可以利用SpringAop特性,通过注解来实现,下边我们来用注解实现切换数据源

定义注解:
1
2
3
4
5
6
7
8
9
10
11
12
package com.example.springbootdynamic.annotation;

import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DataSelect {
// 默认数据源
String value() default "master";
}
实现AOP

定义了@DataSelect注解后,紧接着实现注解的AOP逻辑,拿到注解传递值,然后设置当前线程的数据源

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
package com.example.springbootdynamic.aopconfig;

import com.example.springbootdynamic.annotation.DataSelect;
import com.example.springbootdynamic.config.DataSourceContextHolder;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.Objects;

@Component
@Aspect
public class DSAspect {


@Pointcut("@annotation(com.example.springbootdynamic.annotation.DataSelect)")
public void dynamicDataSource() {
}


@Around("dynamicDataSource()")
public Object datasourceAround(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
DataSelect ds = method.getAnnotation(DataSelect.class);
if (Objects.nonNull(ds)) {
DataSourceContextHolder.setDataSource(ds.value());
}
try {
return point.proceed();
} finally {
DataSourceContextHolder.removeDataSource();
}
}

}
测试注解

再添加两个接口测试,使用@DataSelect注解标注,使用不同的数据源名称,内部执行相同的查询条件,看看结果如何?

package com.example.springbootdynamic.controller;

import com.example.springbootdynamic.annotation.DataSelect;
import com.example.springbootdynamic.config.DataSourceContextHolder;
import com.example.springbootdynamic.entity.Student;
import com.example.springbootdynamic.service.impl.StudentServiceImpl;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class DynamicSwitchController {
    @Resource
    private StudentServiceImpl studentService;

    @GetMapping("/switchDataSource/{datasourceName}")
    public String switchDataSource(@PathVariable("datasourceName") String datasourceName) {
        DataSourceContextHolder.setDataSource(datasourceName);
        List<Student> allStudent = studentService.getAllStudent();
        DataSourceContextHolder.removeDataSource();
        return allStudent.toString();
    }

    @DataSelect
    @GetMapping("/getStudentInSecurity")
    public String getStudentBySecurity() {
        List<Student> allStudent = studentService.getAllStudent();
        return allStudent.toString();
    }

    @DataSelect(value = "slave")
    @GetMapping("/getStudentInSpringSecurity")
    public String getStudent() {
        List<Student> allStudent = studentService.getAllStudent();
        return allStudent.toString();
    }
}

通过执行结果,看到通过应用@DataSelect注解也成功的进行了数据源的切换

源码地址:https://github.com/Breeze1203/JavaAdvanced/tree/main/springboot-demo/spring-boot-dynamic-datasource

3种SpringBoot全局时间格式化配置

时间格式化在项目中使用频率是非常高的,当我们的 API 接口返回结果,需要对其中某一个 date 字段属性进行特殊的格式化处理,通常会用到 SimpleDateFormat 工具处理,代码如下:

1
2
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
Date stationTime = dateFormat.parse(dateFormat.format(PayEndTime()));

这种操作频繁,还产生很多重复臃肿的代码,而此时如果能将时间格式统一配置,就可以省下更多时间专注于业务开发了

@JsonFormat 注解

部分格式化,因为@JsonFormat 注解需要用在实体类的时间字段上,而只有使用相应的实体类,对应的字段才能进行格式化

1
2
3
4
5
6
7
8
9
@Data
public class OrderDTO {

@JsonFormat(locale = "zh", timezone = "GMT+8", pattern = "yyyy-MM-dd")
private LocalDateTime createTime;

@JsonFormat(locale = "zh", timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}

@JsonComponent 注解

使用 @JsonFormat 注解并不能完全做到全局时间格式化,所以接下来我们使用 @JsonComponent 注解自定义一个全局格式化类,分别对 DateLocalDate 类型做格式化处理

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
@JsonComponent
public class DateFormatConfig {

@Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}")
private String pattern;

/**
* @author xiaofu
* @description date 类型全局时间格式化
* @date 2020/8/31 18:22
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilder() {

return builder -> {
TimeZone tz = TimeZone.getTimeZone("UTC");
DateFormat df = new SimpleDateFormat(pattern);
df.setTimeZone(tz);
builder.failOnEmptyBeans(false)
.failOnUnknownProperties(false)
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.dateFormat(df);
};
}

/**
* @author xiaofu
* @description LocalDate 类型全局时间格式化
* @date 2020/8/31 18:22
*/
@Bean
public LocalDateTimeSerializer localDateTimeDeserializer() {
return new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(pattern));
}

@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
return builder -> builder.serializerByType(LocalDateTime.class, localDateTimeDeserializer());
}
}

实际开发中如果我有个字段不想用全局格式化设置的时间样式,想自定义格式怎么办,那就和@JsonFormat注解混合使用,@JsonFormat 注解的优先级比较高,会以 @JsonFormat 注解的时间格式为主。

@Configuration 注解

这种全局配置之后,jsonformat注解将不再生效

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
@Configuration
public class DateFormatConfig2 {

@Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}")
private String pattern;

public static DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

@Bean
@Primary
public ObjectMapper serializingObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer());
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer());
objectMapper.registerModule(javaTimeModule);
return objectMapper;
}

/**
* @author xiaofu
* @description Date 时间类型装换
* @date 2020/9/1 17:25
*/
@Component
public class DateSerializer extends JsonSerializer<Date> {
@Override
public void serialize(Date date, JsonGenerator gen, SerializerProvider provider) throws IOException {
String formattedDate = dateFormat.format(date);
gen.writeString(formattedDate);
}
}

/**
* @author xiaofu
* @description Date 时间类型装换
* @date 2020/9/1 17:25
*/
@Component
public class DateDeserializer extends JsonDeserializer<Date> {

@Override
public Date deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
try {
return dateFormat.parse(jsonParser.getValueAsString());
} catch (ParseException e) {
throw new RuntimeException("Could not parse date", e);
}
}
}

/**
* @author xiaofu
* @description LocalDate 时间类型装换
* @date 2020/9/1 17:25
*/
public class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
@Override
public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeString(value.format(DateTimeFormatter.ofPattern(pattern)));
}
}

/**
* @author xiaofu
* @description LocalDate 时间类型装换
* @date 2020/9/1 17:25
*/
public class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext deserializationContext) throws IOException {
return LocalDateTime.parse(p.getValueAsString(), DateTimeFormatter.ofPattern(pattern));
}
}
}

1.线程概念

线程是进程内的执行单元。

使用线程的原因是,进程的切换是非常重量级的操作,非常消耗资源。如果使用多进程,那么并发数相对来说不会很高。而线程是更细小的调度单元,更加轻量级,所以线程会较为广泛的用于并发设计。

在Java当中线程的概念和操作系统级别线程的概念是类似的。事实上,Jvm将会把Java中的线程映射到操作系统的线程区。

2.线程的基本操作

2.1 新建线程

1
2
Thread thread = new Thread();
thread.start();

需要注意的是

1
2
Thread thread = new Thread();
thread.run();

直接调用run方法是无法开启一个新线程的。

start方法其实是在一个新的操作系统线程上面去调用run方法。换句话说,直接调用run方法而不是调用start方法的话,它并不会开启新的线程,而是在调用run的当前的线程当中执行你的操作。

1
2
3
4
5
6
7
8
9
10
Thread thread = new Thread("t1")
{
@Override
public void run()
{
// TODO Auto-generated method stub
System.out.println(Thread.currentThread().getName());
}
};
thread.start();

如果调用start,则输出是t1

1
2
3
4
5
6
7
8
9
10
Thread thread = new Thread("t1")
{
@Override
public void run()
{
// TODO Auto-generated method stub
System.out.println(Thread.currentThread().getName());
}
};
thread.run();

如果是run,则输出main。(直接调用run其实就是一个普通的函数调用而已,并没有达到多线程的作用)

run方法的实现有两种方式

第一种方式,直接覆盖run方法,就如刚刚代码中所示,最方便的用一个匿名类就可以实现。

1
2
3
4
5
6
7
8
9
Thread thread = new Thread("t1")
{
@Override
public void run()
{
// TODO Auto-generated method stub
System.out.println(Thread.currentThread().getName());
}
};

第二种方式

1
Thread t1=new Thread(new CreateThread3());

CreateThread3()需要实现Runnable接口。

2.2 终止线程

  • Thread.stop() 不推荐使用。它会释放所有monitor

在源码中已经明确说明stop方法被Deprecated,在Javadoc中也说明了原因。

原因在于stop方法太过”暴力”了,无论线程执行到哪里,它将会立即停止掉线程。

当写线程得到锁以后开始写入数据,写完id = 1,在准备将name = 1时被stop,释放锁。读线程获得锁进行读操作,读到的id为1,而name还是0,导致了数据不一致。最重要的是这种错误不会抛出异常,将很难被发现。

2.3 线程中断

线程中断有3种方法

1
2
3
public void Thread.interrupt() // 中断线程 
public boolean Thread.isInterrupted() // 判断是否被中断
public static boolean Thread.interrupted() // 判断是否被中断,并清除当前中断状态

什么是线程中断呢?

如果不了解Java的中断机制,这样的一种解释极容易造成误解,认为调用了线程的interrupt方法就一定会中断线程。
其实,Java的中断是一种协作机制。也就是说调用线程对象的interrupt方法并不一定就中断了正在运行的线程,它只是要求线程自己在合适的时机中断自己。每个线程都有一个boolean的中断状态(不一定就是对象的属性,事实上,该状态也确实不是Thread的字段),interrupt方法仅仅只是将该状态置为true。对于非阻塞中的线程, 只是改变了中断状态, 即Thread.isInterrupted()将返回true,并不会使程序停止;

1
2
3
4
5
6
public void run(){//线程t1
while(true){
Thread.yield();
}
}
t1.interrupt();

这样使线程t1中断,是不会有效果的,只是更改了中断状态位。

如果希望非常优雅地终止这个线程,就该这样做

1
2
3
4
5
6
7
8
9
10
11
public void run(){ 
while(true)
{
if(Thread.currentThread().isInterrupted())
{
System.out.println("Interruted!");
break;
}
Thread.yield();
}
}

使用中断,就对数据一致性有了一定的保证。

对于可取消的阻塞状态中的线程, 比如等待在这些函数上的线程, Thread.sleep(), Object.wait(), Thread.join(), 这个线程收到中断信号后, 会抛出InterruptedException, 同时会把中断状态置回为false.

对于取消阻塞状态中的线程,可以这样书写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void run(){
while(true){
if(Thread.currentThread().isInterrupted()){
System.out.println("Interruted!");
break;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
System.out.println("Interruted When Sleep");
//设置中断状态,抛出异常后会清除中断标记位
Thread.currentThread().interrupt();
}
Thread.yield();
}
}

2.5 线程挂起

挂起(suspend)和继续执行(resume)线程

  • suspend()不会释放锁

  • 如果加锁发生在resume()之前 ,则死锁发生

这两个方法都是Deprecated方法,不推荐使用。

原因在于,suspend不释放锁,因此没有线程可以访问被它锁住的临界区资源,直到被其他线程resume。因为无法控制线程运行的先后顺序,如果其他线程的resume方法先被运行,那则后运行的suspend,将一直占有这把锁,造成死锁发生。

用以下代码来模拟这个场景

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
package test;

public class Test
{
static Object u = new Object();
static TestSuspendThread t1 = new TestSuspendThread("t1");
static TestSuspendThread t2 = new TestSuspendThread("t2");

public static class TestSuspendThread extends Thread
{
public TestSuspendThread(String name)
{
setName(name);
}

@Override
public void run()
{
synchronized (u)
{
System.out.println("in " + getName());
Thread.currentThread().suspend();
}
}
}

public static void main(String[] args) throws InterruptedException
{
t1.start();
Thread.sleep(100);
t2.start();
t1.resume();
t2.resume();
t1.join();
t2.join();
}
}

让t1,t2同时争夺一把锁,争夺到的线程suspend,然后再resume,按理来说,应该某个线程争夺后被resume释放了锁,然后另一个线程争夺掉锁,再被resume。

结果输出是:

1
2
in t1
in t2

说明两个线程都争夺到了锁,但是控制台的红灯还是亮着的,说明t1,t2一定有线程没有执行完。我们dump出堆来看看

发现t2一直被suspend。这样就造成了死锁。

2.6 谦让(yeild)

yeild是个native静态方法,这个方法是想把自己占有的cpu时间释放掉,然后和其他线程一起竞争(注意yeild的线程还是有可能争夺到cpu,注意与sleep区别)。在javadoc中也说明了,yeild是个基本不会用到的方法,一般在debug和test中使用。

2.7 等待结束( join)

join方法的意思是等待其他线程结束,就如suspend那节的代码,想让主线程等待t1,t2结束以后再结束。没有结束的话,主线程就一直阻塞在那里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package test;

public class Test
{
public volatile static int i = 0;

public static class AddThread extends Thread
{
@Override
public void run()
{
for (i = 0; i < 10000000; i++);
}
}

public static void main(String[] args) throws InterruptedException
{
AddThread at = new AddThread();
at.start();
at.join();
System.out.println(i);
}
}

如果把上述代码的at.join去掉,则主线程会直接运行结束,i的值会很小。如果有join,打印出的i的值一定是10000000。

那么join是怎么实现的呢?

join的本质

1
2
3
4
while(isAlive()) 
{
wait(0);
}

join()方法也可以传递一个时间,意为有限期地等待,超过了这个时间就自动唤醒。

这样就有一个问题,谁来notify这个线程呢,在thread类中没有地方调用了notify?

在javadoc中,找到了相关解释。当一个线程运行完成终止后,将会调用notifyAll方法去唤醒等待在当前线程实例上的所有线程,这个操作是jvm自己完成的。

所以javadoc中还给了我们一个建议,不要使用wait和notify/notifyall在线程实例上。因为jvm会自己调用,有可能与你调用期望的结果不同。

3. 线程状态转换图

线程在一定条件下,状态会发生变化。线程一共有以下几种状态

1. 新建状态(New):新创建了一个线程对象。

2. 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于“可运行线程池”中,变得可运行,只等待获取CPU的使用权。即在就绪状态的进程除CPU之外,其它的运行所需资源都已全部获得。

**3. 运行状态(Running):**就绪状态的线程获取了CPU,执行程序代码。

**4. 阻塞状态(Blocked):**阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。

阻塞的情况分三种:

(1)等待阻塞:运行的线程执行wait()方法,该线程会释放占用的所有资源,JVM会把该线程放入等待池中。进入这个状 态后,是不能自动唤醒的,必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒,

(2)同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。

(3)其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

**5、死亡状态(Dead):**线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

线程变化的状态转换图如下:

*

**注:**拿到对象的锁标记,即为获得了对该对象(临界区)的使用权限。即该线程获得了运行所需的资源,进入“就绪状态”,只需获得CPU,就可以运行。因为当调用wait()后,线程会释放掉它所占有的“锁标志”,所以线程只有在此获取资源才能进入就绪状态。

  • 具体解释:
    1、 线程的实现有两种方式,一是继承Thread类,二是实现Runnable接口,当new出一个线程时,其实线程并没有工作它只是生成了一个实体,当你调用这个实例的start方法时,线程才真正地被启动,并且进入Runnable状态;
    2、 当该对象调用了start()方法,就进入就绪状态;
    3、 进入就绪后,当该对象被操作系统选中,获得CPU时间片就会进入运行状态;
    4、 进入运行状态后情况就比较复杂了;

  • run()方法或main()方法结束后,线程就进入终止状态;

  • 当线程调用了自身的sleep()方法或其他线程的join()方法,进程让出CPU,然后就会进入阻塞状态(该状态既停止当前线程,但并不释放所占有的资源即调用sleep()函数后,线程不会释放它的“锁标志”)当sleep()结束或join()结束后,该线程进入可运行状态,继续等待OS分配CPU时间片,典型地,sleep()被用在等待某个资源就绪的情形:测试发现条件不满足后,让线程阻塞一段时间后重新测试,直到条件满足为止;

  • 线程调用了yield()方法,意思是放弃当前获得的CPU时间片,回到就绪状态,这时与其他进程处于同等竞争状态,OS有可能会接着又让这个进程进入运行状态;调用yield()的效果等价于调度程序认为该线程已执行了足够的时间片从而需要转到另一个线程yield()只是使当前线程重新回到可执行状态,所以执行yield()的线程有可能在进入到可执行状态后马上又被执行;

  • 当线程刚进入可运行状态(注意,还没运行),发现将要调用的资源被synchroniza(同步),获取不到锁标记,将会立即进入锁池状态,等待获取锁标记(这时的锁池里也许已经有了其他线程在等待获取锁标记,这时它们处于队列状态,既先到先得),一旦线程获得锁标记后,就转入就绪状态,等待OS分配CPU时间片;

  • suspend()和resume()方法:两个方法配套使用,suspend()使得线程进入阻塞状态,并且不会自动恢复,必须其对应的resume()被调用,才能使得线程重新进入可执行状态典型地,suspend()和resume()被用在等待另一个线程产生的结果的情形:测试发现结果还没有产生后,让线程阻塞,另一个线程产生了结果后,调用resume()使其恢复;

  • wait()和notify()方法:当线程调用wait()方法后会进入等待队列(进入这个状态会释放所占有的所有资源,与阻塞状态不同),进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒(由于notify()只是唤醒一个线程,但我们由不能确定具体唤醒的是哪一个线程,也许我们需要唤醒的线程不能够被唤醒,因此在实际使用时,一般都用notifyAll()方法,唤醒有所线程),线程被唤醒后会进入锁池,等待获取锁标记;

  • wait() 使得线程进入阻塞状态,它有两种形式:

一种允许指定以毫秒为单位的一段时间作为参数;另一种没有参数。前者当对应的 notify()被调用或者超出指定时间时线程重新进入可执行状态即就绪状态,后者则必须对应的 notify()被调用。当调用wait()后,线程会释放掉它所占有的“锁标志”从而使线程所在对象中的其它synchronized数据可被别的线程使用。waite()和notify()因为会对对象的“锁标志”进行操作,所以它们必须在synchronized函数或synchronizedblock中进行调用。如果在non-synchronized函数或non-synchronizedblock中进行调用,虽然能编译通过,但在运行时会发生IllegalMonitorStateException的异常。

**注意区别:**初看起来wait() 和 notify() 方法与suspend()和 resume() 方法对没有什么分别,但是事实上它们是截然不同的。区别的核心在于,前面叙述的suspend()及其它所有方法在线程阻塞时都不会释放占用的锁(如果占用了的话),而wait() 和 notify() 这一对方法则相反。

上述的核心区别导致了一系列的细节上的区别

首先,前面叙述的所有方法都隶属于 Thread类,但是wait() 和 notify() 方法这一对却直接隶属于 Object 类也就是说,所有对象都拥有这一对方法。初看起来这十分不可思议,但是实际上却是很自然的,因为这一对方法阻塞时要释放占用的锁,而锁是任何对象都具有的,调用任意对象的 wait() 方法导致线程阻塞,并且该对象上的锁被释放。而调用任意对象的notify()方法则导致因调用该对象的 wait()方法而阻塞的线程中随机选择的一个解除阻塞(但要等到获得锁后才真正可执行)。

其次,前面叙述的所有方法都可在任何位置调用,但是wait() 和 notify() 方法这一对方法却必须在 synchronized 方法或块中调用,理由也很简单,只有在synchronized方法或块中当前线程才占有锁,才有锁可以释放。同样的道理,调用这一对方法的对象上的锁必须为当前线程所拥有,这样才有锁可以释放。因此,这一对方法调用必须放置在这样的 synchronized方法或块中,该方法或块的上锁对象就是调用这一对方法的对象。若不满足这一条件,则程序虽然仍能编译,但在运行时会出现IllegalMonitorStateException异常。

wait() 和 notify()方法的上述特性决定了它们经常和synchronized方法或块一起使用,将它们和操作系统的进程间通信机制作一个比较就会发现它们的相似性:synchronized方法或块提供了类似于操作系统原语的功能,它们的执行不会受到多线程机制的干扰,而这一对方法则相当于 block和wake up 原语(这一对方法均声明为 synchronized)。它们的结合使得我们可以实现操作系统上一系列精妙的进程间通信的算法(如信号量算法),并用于解决各种复杂的线程间通信问题。

关于 wait() 和 notify() 方法最后再说明两点:

第一:调用notify() 方法导致解除阻塞的线程是从因调用该对象的 wait()方法而阻塞的线程中随机选取的,我们无法预料哪一个线程将会被选择,所以编程时要特别小心,避免因这种不确定性而产生问题。

第二:除了notify(),还有一个方法 notifyAll()也可起到类似作用,唯一的区别在于,调用 notifyAll()方法将把因调用该对象的 wait()方法而阻塞的所有线程一次性全部解除阻塞。当然,只有获得锁的那一个线程才能进入可执行状态。

谈到阻塞,就不能不谈一谈死锁,略一分析就能发现,suspend()方法和不指定超时期限的wait()方法的调用都可能产生死锁。遗憾的是,Java并不在语言级别上支持死锁的避免,我们在编程中必须小心地避免死锁

我们看到用户是如何被重定向到登录表单的。

loginurlauthenticationentrypoint

上图建立在 SecurityFilterChain 图上。

  1. 首先,一个用户向其未被 授权的资源(/private)发出一个未经 认证的请求。

  2. spring Security 的 AuthorizationFilter 通过抛出一个 AccessDeniedException 来表明未经 认证的请求被拒绝了。

  3. 由于用户没有被认证,ExceptionTranslationFilter 启动了 Start  Authentication,并发送一个重定向到配置的 AuthenticationEntryPoint 的登录页面。在大多数情况下, AuthenticationEntryPoint 是 LoginUrlAuthenticationEntryPoint 的一个实例。

  4. 浏览器请求进入其被重定向的登录页面。应用程序中的某些东西,必须渲染登录页面。

  5. 当用户名和密码被提交后,UsernamePasswordAuthenticationFilter 会对用户名和密码进行 认证。UsernamePasswordAuthenticationFilter 扩展了 AbstractAuthenticationProcessingFilter,所以下面的图看起来应该很相似。

usernamepasswordauthenticationfilter

上图建立在 SecurityFilterChain 图上。

  1. 当用户提交他们的用户名和密码时,UsernamePasswordAuthenticationFilter 通过从 HttpServletRequest 实例中提取用户名和密码,创建一个 UsernamePasswordAuthenticationToken,这是一种 Authentication 类型。

  2. 接下来,UsernamePasswordAuthenticationToken 被传入 AuthenticationManager 实例,以进行 认证。AuthenticationManager 的细节取决于 用户信息的存储方式。

如果 认证失败,则为 Failure.

  1. SecurityContextHolder 被清空。

  2. RememberMeServices.loginFail 被调用。如果没有配置remember me,这就是一个无用功。参见Javadoc中的 RememberMeServices 接口。

  3. AuthenticationFailureHandler 被调用。参见Javadoc中的 AuthenticationFailureHandler 类。

如果 认证成功,则 Success。

  1. SessionAuthenticationStrategy 被通知有新的登录。参见Javadoc中的 SessionAuthenticationStrategy 接口。

  2. Authentication 被设置在 SecurityContextHolder 上。参见 Javadoc 中的 SecurityContextPersistenceFilter 类。

  3. RememberMeServices.loginSuccess 被调用。如果没有配置remember me,这就是一个无用功。参见Javadoc中的 RememberMeServices 接口。

  4. ApplicationEventPublisher 发布 InteractiveAuthenticationSuccessEvent 事件。

  5. AuthenticationSuccessHandler 被调用。通常,这是一个 SimpleUrlAuthenticationSuccessHandler,当我们重定向到登录页面时,它会重定向到由 ExceptionTranslationFilter 保存的请求。

默认情况下, Spring Security表单登录被启用。然而,只要提供任何基于Servlet的配置,就必须明确提供基于表单的登录。下面的例子显示了一个最小的、明确的Java配置。

1
2
3
4
5
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.formLogin(withDefaults());
// ...
}

Copied!

在前面的配置中, Spring Security渲染了一个默认的登录页面。大多数生产 应用需要一个自定义的登录表单。

下面的配置演示了如何提供一个自定义的登录表单。

1
2
3
4
5
6
7
8
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.formLogin(form -> form
.loginPage("/login")
.permitAll()
);
// ...
}

Copied!

XML

1
2
3
4
5
<http>
<!-- ... -->
<intercept-url pattern="/login" access="permitAll" />
<form-login login-page="/login" />
</http>

Copied!ied!

当登录页面在 Spring Security配置中被指定时,你要负责渲染该页面。 下面的 Thymeleaf 模板产生一个符合 /login 的登录页面的HTML登录表单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log In</title>
</head>
<body>
<h1>Please Log In</h1>
<div th:if="${param.error}">
Invalid username and password.</div>
<div th:if="${param.logout}">
You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<input type="text" name="username" placeholder="Username"/>
</div>
<div>
<input type="password" name="password" placeholder="Password"/>
</div>
<input type="submit" value="Log in" />
</form>
</body>
</html>

Copied!

关于默认的HTML表单,有几个关键点。

  • 表单应该以 post 方法请求 /login。

  • 该表单需要包含 CSRF Token,Thymeleaf 会 自动包含。

  • 该表单应在一个名为 username 的参数中指定用户名。

  • 表单应该在一个名为 password 的参数中指定密码。

  • 如果发现名为 error 的HTTP参数,表明用户未能提供一个有效的用户名或密码。

  • 如果发现名为 logout 的HTTP参数,表明用户已经成功注销

许多用户除了定制登录页面外,并不需要更多的东西。然而,如果需要的话,你可以通过额外的配置来定制前面显示的一切。

如果你使用 Spring MVC,你需要一个控制器,将 GET /login 映射到我们创建的登录模板。下面的例子展示了一个最小的 LoginController。

LoginController

1
2
3
4
5
6
7
@Controller
class LoginController {
@GetMapping("/login")
String login() {
return "login";
}
}

补充:实际应用中,我们可能采用前后端架构,或者自定义登录请求界面,这种情况下,我们只需要将登录请求与.loginProcessingUrl(“/login”)保持一致,还的注意请求类型,请求参数和请求方法

下面,就是一个简单的模拟登录请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import axios from 'axios';

// 定义表单数据
const formData = new URLSearchParams();
formData.append('username', 'yourUsername');
formData.append('password', 'yourPassword');

// 设置请求头
const config = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
};

// 发送 POST 请求
axios.post('/login', formData, config)
.then(response => {
console.log('Response:', response.data);
// 处理响应
})
.catch(error => {
console.error('Error:', error);
// 处理错误
});

这种情况只适用于springsecurity关闭了csrf验证,如果没关闭的要加上_csrf参数和值(但是springsecurity这个值是默认生成的,我们可以自定义生成csrf,然后存储起来,发送请求获取csrftoken)具体看csrf那章

Spring Security 过滤器自定义登录

Spring Security所有过滤器及顺序

  1. ChannelProcessingFilter:使用https还是http的通过过滤器

  2. WebAsyncManagerIntegrationFilter:此过滤器使得WebAsync异步线程能够获取到当前认证信息

  3. SecurityContextPersistenceFilter:主要控制 SecurityContext 的在一次请求中的生命周期,请求结束时清空,防止内存泄漏

  4. HeaderWriterFilter:请求头过滤器

  5. CorsFilter:跨域过滤器

  6. CsrfFilter:csrf过滤器

  7. LogoutFilter:登出过滤器

  8. OAuth2AuthorizationRequestRedirectFilter:Oauth2请求鉴权重定向过滤器,需配合OAuth2.0的模块使用

  9. Saml2WebSsoAuthenticationRequestFilter:Saml2单点认证过滤器 需配合Spring Security SAML模块使用

  10. X509AuthenticationFilter:X.509证书认证过滤器

  11. AbstractPreAuthenticatedProcessingFilter:处理经过预先认证的身份验证请求的过滤器的基类

  12. CasAuthenticationFilter:CAS 单点登录认证过滤器 。配合Spring Security CAS模块使用

  13. OAuth2LoginAuthenticationFilter:OAuth2 登录认证过滤器
    Saml2WebSsoAuthenticationFilter:SMAL 的 SSO 单点登录认证过滤器

  14. UsernamePasswordAuthenticationFilter:用户名密码认证过滤器

  15. OpenIDAuthenticationFilter:OpenID认证过滤器

  16. DefaultLoginPageGeneratingFilter:默认登入页生成过滤器

  17. DefaultLogoutPageGeneratingFilter:默认登出页生成过滤器

  18. ConcurrentSessionFilter:session管理,用于判断session是否过期

  19. DigestAuthenticationFilter:摘要认证过滤器

  20. BearerTokenAuthenticationFilter:Bearer标准token认证过滤器

  21. BasicAuthenticationFilter:Http Basic标准认证过滤器

  22. RequestCacheAwareFilter:请求缓存过滤器,主要作用是认证完成后恢复认证前的请求继续执行

  23. SecurityContextHolderAwareRequestFilter:对request包装的目的主要是实现servlet api的一些接口方法isUserInRole、getRemoteUser

  24. JaasApiIntegrationFilter:Jaas认证过滤器

  25. RememberMeAuthenticationFilter:RememberMe 认证过滤器

  26. AnonymousAuthenticationFilter:匿名认证过滤器

  27. OAuth2AuthorizationCodeGrantFilter:OAuth2授权码过滤器

  28. SessionManagementFilter:Session 管理器过滤器,内部维护了一个SessionAuthenticationStrategy 用于管理 Session

  29. ExceptionTranslationFilter:异常翻译过滤器

  30. FilterSecurityInterceptor:请求鉴权过滤器

  31. SwitchUserFilter:账户切换过滤器

自定义过滤器登录思路

  1. 在SpringSecurity登录之前增加一个过滤器拿到账号密码,然后设置到SpringSecurity的request parameter中:不推荐

  2. 继承:AbstractAuthenticationProcessingFilter,或者UsernamePasswordAuthenticationFilter,在SecurityConfig中配置如下,这种方式属于替换SpringSecurity默认的登录过滤器:推荐

1
http.addFilterAt(loginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

索引概念和作用

索引是一种使记录有序化的技术,它可以指定按某列/某几列预先排序,从而大大提高查询速度(类似于汉语词典中按照拼音或者笔画查找)。

索引的主要作用是加快数据查找速度,提高数据库的性能。

MySQL 索引类型

从物理存储角度上,索引可以分为聚集索引和非聚集索引。

1. 聚集索引(Clustered Index)

聚集索引决定数据在磁盘上的物理排序,一个表只能有一个聚集索引。

2. 非聚集索引(Non-clustered Index)

非聚集索引并不决定数据在磁盘上的物理排序,索引上只包含被建立索引的数据,以及一个行定位符 row-locator,这个行定位符,可以理解为一个聚集索引物理排序的指针,通过这个指针,可以找到行数据。

从逻辑角度,索引可以分为以下几种。

  1. 普通索引:最基本的索引,它没有任何限制。

  2. 唯一索引:与普通索引类似,不同的就是索引列的值必须唯一,但允许有空值。如果是组合索引,则列值的组合必须唯一。

  3. 主键索引:它是一种特殊的唯一索引,用于唯一标识数据表中的某一条记录,不允许有空值,一般用 primary key 来约束。主键和聚集索引的关系详见“问题详解”中的第4题。

  4. 联合索引(又叫复合索引):多个字段上建立的索引,能够加速复合查询条件的检索。

  5. 全文索引:老版本 MySQL 自带的全文索引只能用于数据库引擎为 MyISAM 的数据表,新版本 MySQL 5.6 的 InnoDB 支持全文索引。默认 MySQL 不支持中文全文检索,可以通过扩展 MySQL,添加中文全文检索或为中文内容表提供一个对应的英文索引表的方式来支持中文。

MySQL索引优化规则

可以通过以下规则对 MySQL 索引进行优化。

1.前导模糊查询不能使用索引。

例如下面 SQL 语句不能使用索引。

1
select * fromdoc where title like%XX’
而非前导模糊查询则可以使用索引,如下面的 SQL 语句。
1
select * fromdoc where title like ‘XX%

页面搜索严禁左模糊或者全模糊,如果需要可以用搜索引擎来解决。

2.union、in、or 都能够命中索引,建议使用 in。
  • union:能够命中索引。

示例代码如下:

select * fromdoc where status=1
unionall
select * fromdoc where status=2

直接告诉 MySQL 怎么做,MySQL 耗费的 CPU 最少,但是一般不这么写 SQL。

  • in:能够命中索引。

示例代码如下:

1
select * fromdoc where status in (1, 2)

查询优化耗费的 CPU 比 union all 多,但可以忽略不计,一般情况下建议使用 in

  • or:新版的 MySQL 能够命中索引。

示例代码如下:

1
select * fromdoc where status = 1 or status = 2

查询优化耗费的 CPU 比 in 多,不建议频繁用 or。

3.负向条件查询不能使用索引,可以优化为 in 查询。

负向条件有:!=、<>、not in、not exists、not like 等。

例如下面代码:

1
select * fromdoc where status != 1 and status != 2

可以优化为 in 查询:

1
select * fromdoc where status in (0,3,4)
4.联合索引最左前缀原则(又叫最左侧查询)
  • 如果在(a,b,c)三个字段上建立联合索引,那么它能够加快 a

    (a,b) (a,b,c) 三组查询速度。

例如登录业务需求,代码如下。

1
selectuid, login_time from user where login_name=? andpasswd=?

可以建立(login_name, passwd)的联合索引。

因为业务上几乎没有 passwd 的单条件查询需求,而有很多 login_name 的单条件查询需求,所以可以建立(login_name, passwd)的联合索引,而不是(passwd, login_name)。

  • 建联合索引的时候,区分度最高的字段在最左边。

  • 如果建立了(a,b)联合索引,就不必再单独建立 a 索引。同理,如果建立了(a,b,c)联合索引,就不必再单独建立 a、(a,b) 索引。

  • 存在非等号和等号混合判断条件时,在建索引时,请把等号条件的列前置。如 where a>? and b=?,那么即使 a 的区分度更高,也必须把 b 放在索引的最前列。

  • 最左侧查询需求,并不是指 SQL 语句的 where 顺序要和联合索引一致。

下面的 SQL 语句也可以命中 (login_name, passwd) 这个联合索引。

1
selectuid, login_time from user where passwd=? andlogin_name=?

但还是建议 where 后的顺序和联合索引一致,养成好习惯。

5.范围列可以用到索引(联合索引必须是最左前缀)。
  • 范围条件有:<、<=、>、>=、between等。

  • 范围列可以用到索引(联合索引必须是最左前缀),但是范围列后面的列无法用到索引,索引最多用于一个范围列,如果查询条件中有两个范围列则无法全用到索引。

假如有联合索引 (empno、title、fromdate),那么下面的 SQL 中 emp_no 可以用到索引,而 title 和 from_date 则使用不到索引。

1
select * fromemployees.titles where emp_no < 10010and title=’Senior Engineer’and from_date between1986-01-01and1986-12-31
6.把计算放到业务层而不是数据库层。
  • 在字段上进行计算不能命中索引。

例如下面的 SQL 语句。

1
select * fromdoc where YEAR(create_time) <=2016

即使 date 上建立了索引,也会全表扫描,可优化为值计算,如下:

1
select * fromdoc where create_time <=2016-01-01
  • 把计算放到业务层。

这样做不仅可以节省数据库的 CPU,还可以起到查询缓存优化效果。

比如下面的 SQL 语句:

1
select * fromorder where date < = CURDATE()

可以优化为:

1
select * fromorder where date < =2018-01-2412:00:00

优化后的 SQL 释放了数据库的 CPU 多次调用,传入的 SQL 相同,才可以利用查询缓存。

7.强制类型转换会全表扫描

如果 phone 字段是 varchar 类型,则下面的 SQL 不能命中索引。

1
select * fromuser where phone=13800001234

可以优化为:

1
select * fromuser where phone=13800001234
8.更新十分频繁、数据区分度不高的字段上不宜建立索引。
  • 更新会变更 B+ 树,更新频繁的字段建立索引会大大降低数据库性能。

  • “性别”这种区分度不大的属性,建立索引是没有什么意义的,不能有效过滤数据,性能与全表扫描类似。

  • 一般区分度在80%以上的时候就可以建立索引,区分度可以使用 count(distinct(列名))/count(*) 来计算。

9.利用覆盖索引来进行查询操作,避免回表。

被查询的列,数据能从索引中取得,而不用通过行定位符 row-locator 再到 row 上获取,即“被查询列要被所建的索引覆盖”,这能够加速查询速度。

例如登录业务需求,代码如下。

1
select uid, login_time from user where login_name=? andpasswd=?

可以建立(login_name, passwd, login_time)的联合索引,由于 login_time 已经建立在索引中了,被查询的 uid 和 login_time 就不用去 row 上获取数据了,从而加速查询。

10.如果有 order by、group by 的场景,请注意利用索引的有序性。
  • order by 最后的字段是组合索引的一部分,并且放在索引组合顺序的最后,避免出现 file_sort 的情况,影响查询性能。

  • 例如对于语句 where a=? and b=? order by c,可以建立联合索引(a,b,c)。

  • 如果索引中有范围查找,那么索引有序性无法利用,如 WHERE a>10 ORDER BY b;,索引(a,b)无法排序。

11.使用短索引(又叫前缀索引)来优化索引。

前缀索引,就是用列的前缀代替整个列作为索引 key,当前缀长度合适时,可以做到既使得前缀索引的区分度接近全列索引,同时因为索引 key 变短而减少了索引文件的大小和维护开销,可以使用 count(distinct left(列名, 索引长度))/count(*) 来计算前缀索引的区分度。

前缀索引兼顾索引大小和查询速度,但是其缺点是不能用于 ORDER BY 和 GROUP BY 操作,也不能用于覆盖索引(Covering Index,即当索引本身包含查询所需全部数据时,不再访问数据文件本身),很多时候没必要对全字段建立索引,根据实际文本区分度决定索引长度即可。

例如对于下面的 SQL 语句:

1
SELEC *FROM employees.employees WHERE first_name=’Eric’AND last_name=’Anido’;

我们可以建立索引:(firstname, lastname(4))。

12.建立索引的列,不允许为 null。

单列索引不存 null 值,复合索引不存全为 null 的值,如果列允许为 null,可能会得到“不符合预期”的结果集,所以,请使用 not null 约束以及默认值。

13.利用延迟关联或者子查询优化超多分页场景。

MySQL 并不是跳过 offset 行,而是取 offset+N 行,然后返回放弃前 offset 行,返回 N 行,那当 offset 特别大的时候,效率就非常的低下,要么控制返回的总页数,要么对超过特定阈值的页数进行 SQL 改写。

示例如下,先快速定位需要获取的 id 段,然后再关联:

1
selecta.* from1 a,(select id from1 where 条件 limit100000,20 ) b where a.id=b.id
14.业务上具有唯一特性的字段,即使是多个字段的组合,也必须建成唯一索引。

不要以为唯一索引影响了 insert 速度,这个速度损耗可以忽略,但提高查找速度是明显的。另外,即使在应用层做了非常完善的校验控制,只要没有唯一索引,根据墨菲定律,必然有脏数据产生。

15.超过三个表最好不要 join。

需要 join 的字段,数据类型必须一致,多表关联查询时,保证被关联的字段需要有索引。

16.如果明确知道只有一条结果返回,limit 1 能够提高效率。

比如如下 SQL 语句:

1
select * fromuser where login_name=?

可以优化为:

1
select * fromuser where login_name=? limit 1

自己明确知道只有一条结果,但数据库并不知道,明确告诉它,让它主动停止游标移动。

17.SQL 性能优化 explain 中的 type:至少要达到 range 级别,要求是 ref 级别,如果可以是 consts 最好。
  • consts:单表中最多只有一个匹配行(主键或者唯一索引),在优化阶段即可读取到数据。

  • ref:使用普通的索引(Normal Index)。

  • range:对索引进行范围检索。

  • 当 type=index 时,索引物理文件全扫,速度非常慢。

18.单表索引建议控制在5个以内。
19.单索引字段数不允许超过5个。

字段超过5个时,实际已经起不到有效过滤数据的作用了。

20.创建索引时避免以下错误观念
  • 索引越多越好,认为一个查询就需要建一个索引。

  • 宁缺勿滥,认为索引会消耗空间、严重拖慢更新和新增速度。

  • 抵制惟一索引,认为业务的惟一性一律需要在应用层通过“先查后插”方式解决。

  • 过早优化,在不了解系统的情况下就开始优化。

问题详解

这部分,我将列出平时会遇到的一些问题,并给予解答。

1. 请问如下三条 SQL 该如何建立索引?
1
2
3
4
5
where a=1 and b=1

where b=1

where b=1 order by time desc

MySQL 的查询优化器会自动调整 where 子句的条件顺序以使用适合的索引吗?

回答:

第一问:建议建立两个索引,即 idxab(a,b) 和 idxbtime(b,time)。

第二问:MySQL 的查询优化器会自动调整 where 子句的条件顺序以使用适合的索引,对于上面的第一条 SQL,如果建立索引为 idxba(b,a) 也是可以用到索引的,不过建议 where 后的字段顺序和联合索引保持一致,养成好习惯。

2.假如有联合索引(empno、title、fromdate),下面的 SQL 是否可以用到索引,如果可以的话,会使用几个列?
1
select * fromemployees.titles where emp_no between10001and10010’ andtitle=’Senior Engineer’ and from_date between1986-01-01and1986-12-31

回答:可以使用索引,可以用到索引全部三个列,这个 SQL 看起来是用了两个范围查询,但作用于 empno 上的“between”实际上相当于“in”,也就是说 empno 实际是多值精确匹配,在 MySQL 中要谨慎地区分多值匹配和范围匹配,否则会对 MySQL 的行为产生困惑。

3.既然索引可以加快查询速度,那么是不是只要是查询语句需要,就建上索引?

回答:不是,因为索引虽然加快了查询速度,但索引也是有代价的。索引文件本身要消耗存储空间,同时索引会加重插入、删除和修改记录时的负担。另外,MySQL 在运行时也要消耗资源维护索引,因此索引并不是越多越好。一般两种情况下不建议建索引。第一种情况是表记录比较少,例如一两千条甚至只有几百条记录的表,没必要建索引,另一种是数据的区分度比较低,可以使用 count(distinct(列名))/count(*) 来计算区分度。

4.主键和聚集索引的关系?

回答:在 MySQL 中,InnoDB 引擎表是(聚集)索引组织表(Clustered IndexOrganize Table),它会先按照主键进行聚集,如果没有定义主键,InnoDB 会试着使用唯一的非空索引来代替,如果没有这种索引,InnoDB 就会定义隐藏的主键然后在上面进行聚集。由此可见,在 InnoDB 表中,主键必然是聚集索引,而聚集索引则未必是主键。MyISAM 引擎表是堆组织表(Heap Organize Table),它没有聚集索引的概念。

5.一个6亿的表 a,一个3亿的表 b,通过外键 tid 关联,如何最快的查询出满足条件的第50000到第50200中的这200条数据记录?

回答:方法一:如果 a 表 tid 是自增长,并且是连续的,b表的id为索引。SQL语句如下。

1
select * froma,b where a.tid = b.id and a.tid>500000 limit200;

方法二:如果 a 表的 tid 不是连续的,那么就需要使用覆盖索引,tid 要么是主键,要么是辅助索引,b 表 id 也需要有索引。SQL语句如下。

1
select * fromb, (select tid from a limit 50000,200) awhere b.id = a.tid;
6.假如建立联合索引(a,b,c),下列语句是否可以使用索引,如果可以,使用了那几列?(考察联合索引最左前缀原则)

where a= 3

答:是,使用了 a 列。

where a= 3 and b = 5

答:是,使用了 a,b 列。

where a = 3 and c = 4 and b = 5

答:是,使用了 a,b,c 列。

where b= 3

答:否。

where a= 3 and c = 4

答:是,使用了 a 列。

where a = 3 and b > 10 andc = 7

答:是,使用了 a,b 列。

where a = 3 and b like ‘xx%’ andc = 7

答:是,使用了 a,b 列。

7.文章表的表结构如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CREATE TABLEIF NOT EXISTS article (id int(10) unsigned NOTNULLAUTO_INCREMENT,

author_id int(10) unsignedNOT NULL,

category_id int(10) unsigned NOT NULL,

viewsint(10) unsignedNOT NULL,

comments int(10) unsignedNOT NULL,

titlevarbinary(255) NOT NULL,

content text NOTNULL,

PRIMARY KEY (id)

);

下面语句应该如何建立索引?

1
2
3
4
5
selectauthor_id, title, content from article

wherecategory_id = 1 and comments > 1

order byviews desc limit 1;

回答:

没有联合索引时,explain显示,如下图所示:

创建 idxcategoryidcommentsviews(category_id,comments, views) 联合索引时,explain显示,如下图所示:

创建 idxcategoryidviews(categoryid,views) 联合索引,explain 显示,如下图所示:

由此可见,可以创建 idxcategoryidviews(categoryid,views) 联合索引

今天逛github,后台管理系统的时候,看见满多系统前端页面都有水印效果,就尝试实现一下,直接上代码

react版本

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
import React, {useState, useEffect, useRef} from 'react';

const WatermarkStaggered = ({
text = "Mr.彭涛",
textColor = "rgba(180, 180, 180, 0.6)", // 水印文字颜色和透明度
fontSize = 18, // 水印文字大小
angle = -30, // 旋转角度
rowHeight = 150, // 每个水印“行”的近似高度 (px)
zIndex = -1, // z-index
}) => {
const [watermarkItems, setWatermarkItems] = useState([]);
const containerRef = useRef(null);
useEffect(() => {
const calculateAndSetWatermarks = () => {
if (!containerRef.current) {
return;
}

const {clientWidth, clientHeight} = containerRef.current;
if (clientWidth === 0 || clientHeight === 0) {
return;
}
const newItems = [];
const numEffectiveRows = Math.ceil(clientHeight / rowHeight);
let itemKey = 0;
for (let i = 0; i < numEffectiveRows; i++) {
const isFourItemsRow = i % 2 === 0; // 0, 2, 4...行是4个;1, 3, 5...行是3个
const itemsInThisRow = isFourItemsRow ? 4 : 3;
// 计算当前行水印的Y轴中心位置
const currentY = (i + 0.5) * rowHeight;
for (let j = 0; j < itemsInThisRow; j++) {
// 计算当前水印在行内的X轴中心位置 (百分比)
const currentXPercent = ((j + 0.5) / itemsInThisRow) * 100;
newItems.push({
id: `staggered-wm-${itemKey++}`,
style: {
position: 'absolute',
top: `${currentY}px`,
left: `${currentXPercent}%`,
transform: `translate(-50%, -50%) rotate(${angle}deg)`, // 使计算点为文本中心
fontSize: `${fontSize}px`,
color: textColor,
whiteSpace: 'nowrap', // 防止文本换行
userSelect: 'none', // 禁止选中文本
pointerEvents: 'none', // 允许点击穿透
},
});
}
}
setWatermarkItems(newItems);
};


// 延迟初次计算,确保容器尺寸稳定后再执行水印布局计算。
const timerId = setTimeout(calculateAndSetWatermarks, 0);

// 创建 ResizeObserver 实例,用于监听容器DOM元素的尺寸变化。
const resizeObserver = new ResizeObserver(entries => {
// 当容器尺寸变化时,entries[0]会包含变化信息。
if (entries && entries[0]) {
calculateAndSetWatermarks(); // 重新计算并设置水印布局。
}
});

// 如果容器的ref已经绑定到DOM元素,则开始观察其尺寸。
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}

// 清理函数:在组件卸载或此Effect下一次运行前执行。
return () => {
clearTimeout(timerId); // 清除可能还未执行的延迟计算。

if (containerRef.current) {
// 停止对容器DOM元素的尺寸监听,防止内存泄漏。
// eslint-disable-next-line react-hooks/exhaustive-deps
resizeObserver.unobserve(containerRef.current);
}
// 或者直接调用 resizeObserver.disconnect(); 来停止所有观察。
};
}, [text, textColor, fontSize, angle, rowHeight]); // 依赖项数组:

return (
<div
ref={containerRef}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden', // 防止旋转的文本溢出导致滚动条
zIndex: zIndex,
pointerEvents: 'none', // 容器本身也应允许事件穿透
}}
>
{watermarkItems.map(wm => (
<div key={wm.id} style={wm.style}>
{text}
</div>
))}
</div>
);
};

export default WatermarkStaggered;

使用

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
// src/App.js
import React from 'react';
import Watermark from './components/Watermark';
import SamplePage from './pages/SamplePage';
import './App.css';

function App() {
return (
<div className="app-container">
<Watermark text="Mr.彭涛" zIndex={10}/>
<div className="content-wrapper">
<header className="app-header">
<h1>后台管理系统</h1>
</header>
<main className="app-main">
<SamplePage title="最新更新" />
<SamplePage title="数据分析" customContent="这里是数据分析页面的特定内容。" />
{/* 在这里可以集成 React Router 来管理多个页面 */}
</main>
<footer className="app-footer">
<p>&copy; 2025 Your Company</p>
</footer>
</div>
</div>
);
}

export default App;

vue版本

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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
<template>
<div
ref="containerRef"
:style="{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden', // 防止旋转的文本溢出导致滚动条
zIndex: props.zIndex,
pointerEvents: 'none', // 容器本身也应允许事件穿透
}"
>
<div
v-for="wm in watermarkItems"
:key="wm.id"
:style="wm.style"
>
{{ props.text }}
</div>
</div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';

// 1. 定义 Props,与 React 版本保持一致
const props = defineProps({
text: {
type: String,
default: "Mr.彭涛",
},
textColor: {
type: String,
default: "rgba(180, 180, 180, 0.6)", // 与React代码中的默认值一致
},
fontSize: {
type: Number,
default: 18,
},
angle: {
type: Number,
default: -30,
},
rowHeight: {
type: Number,
default: 150,
},
zIndex: {
type: Number,
default: -1,
},
});

// 2. 定义响应式状态和模板引用
const watermarkItems = ref([]); // 存储计算出的水印项
const containerRef = ref(null); // 用于获取容器DOM元素的引用

// 3. 水印计算逻辑 (与React版本核心逻辑相同)
const calculateAndSetWatermarks = () => {
if (!containerRef.value) {
// 容器DOM元素尚未准备好
return;
}

const { clientWidth, clientHeight } = containerRef.value;
if (clientWidth === 0 || clientHeight === 0) {
// 容器尺寸为0,无法计算
return;
}

const newItems = [];
const numEffectiveRows = Math.ceil(clientHeight / props.rowHeight);
let itemKey = 0;

for (let i = 0; i < numEffectiveRows; i++) {
const isFourItemsRow = i % 2 === 0;
const itemsInThisRow = isFourItemsRow ? 4 : 3;
const currentY = (i + 0.5) * props.rowHeight;

for (let j = 0; j < itemsInThisRow; j++) {
const currentXPercent = ((j + 0.5) / itemsInThisRow) * 100;
newItems.push({
id: `staggered-wm-${itemKey++}`,
style: {
position: 'absolute',
top: `${currentY}px`,
left: `${currentXPercent}%`,
transform: `translate(-50%, -50%) rotate(${props.angle}deg)`,
fontSize: `${props.fontSize}px`,
color: props.textColor,
whiteSpace: 'nowrap',
userSelect: 'none',
pointerEvents: 'none',
},
});
}
}
watermarkItems.value = newItems;
};

// 4. 处理生命周期和响应式更新
let resizeObserverInstance = null;

onMounted(() => {
// 组件挂载后,DOM元素可用
// 使用 nextTick 确保在DOM完全渲染和尺寸计算稳定后再执行初次计算
// 这类似于React中useEffect内使用setTimeout(fn, 0)的效果
nextTick(() => {
calculateAndSetWatermarks();
});

// 创建并启动 ResizeObserver
if (containerRef.value) {
resizeObserverInstance = new ResizeObserver(() => {
// 当容器尺寸变化时,重新计算水印
calculateAndSetWatermarks();
});
resizeObserverInstance.observe(containerRef.value);
}
});

onUnmounted(() => {
// 组件卸载前,清理 ResizeObserver
if (resizeObserverInstance) {
if (containerRef.value) { // 确保元素仍存在,尽管通常observer会自己处理
resizeObserverInstance.unobserve(containerRef.value);
}
resizeObserverInstance.disconnect(); // 更彻底的清理
resizeObserverInstance = null;
}
});

// 监听影响布局的props的变化
watch(
// 监听的源:一个返回包含所有相关props的数组的getter函数
() => [props.text, props.textColor, props.fontSize, props.angle, props.rowHeight],
() => {
// 当任何一个被监听的prop变化时,重新计算水印
// 同样使用nextTick,以防prop变化引起DOM reflow影响尺寸读取
nextTick(() => {
calculateAndSetWatermarks();
});
},
{
// deep: false, // 对于这些基本类型和顶层对象属性,不需要深度监听
// immediate: false // 不在watcher创建时立即执行,onMounted已处理首次加载
}
);

</script>

<style scoped>

</style>
0%