Coding & Life

求知若饥,虚心若愚

1.前言

本篇将通过Spring Boot 2.x来讲解Spring Security中的用户主题UserDetails

2.Spring Boot集成Spring Security

集成Spring Security只需要引入其对应的Starter组件。Spring Security不仅仅能保Servlet Web应用,也可以保护Reactive Web应用,本文我们讲前者。我们只需要在Spring Security项目引入以下依赖即可:

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
<dependencies>
<!-- actuator 指标监控 非必须 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- spring security starter 必须 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- spring mvc servlet web 必须 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- lombok 插件 非必须 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

3.UserDetailsServiceAutoConfiguration

启动项目,访问Actuator端点http://localhost:8080/actuator会跳转到一个登录页面,如下:

登录页面

要求你输入用户名Username(默认值为user)和密码Password。密码在springboot控制台会打印出类似Using generated security password: e1f163be-ad18-4be1-977c-88a6bcee0d37的字样,后面的长串就是密码,当然这不是生产可用的。如果你足够细心会从控制台打印日志发现该随机密码是由UserDetailsServiceAutoConfiguration配置类生成的,我们就从它开始顺藤摸瓜来一探究竟。

3.1 UserDetailsService

UserDetailsService接口只提供了一个方法

1
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

该方法很容易理解:通过用户名来加载用户。这个方法主要用于从系统数据中查询并加载具体的用户到Spring Security中。

3.2 UserDetails

从上面UserDetailsService可以知道最终交给Spring Security的是UserDetails。该接口是提供用户信息的核心接口。该接口实现仅仅存储用户信息。后续会将该接口提供的用户信息封装到认证对象Authentication中取。UserDetails默认提供了:

. 用户的权限集,默认需要添加ROLE_前缀
. 用户的加密后的密码,不加密会使用{noop}前缀
. 应用内唯一的用户名
. 账户是否过期
. 账户是否锁定
. 凭证是否过期
. 用户是否可用

如果以上的信息满足不了你使用,你可以自行实现扩展以存储更多的用户信息。比如用户的邮箱、手机号等等。通常我们使用其实现类:

1
org.springframework.security.core.userdetails.User

该类内置一个建造起UserBuilder会很方便地帮助我们构建UserDetails对象,后面我们会用到它

3.3 UserDetailsServiceAutoConfiguration

UserDetailsServiceAutoconfiguration全限定名为:

1
org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration

源码如下:

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
@Configuration
@ConditionalOnClass(AuthenticationManager.class)
@ConditionalOnBean(ObjectPostProcessor.class)
@ConditionalOnMissingBean({ AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class })
public class UserDetailsServiceAutoConfiguration {

private static final String NOOP_PASSWORD_PREFIX = "{noop}";

private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");

private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);

@Bean
@ConditionalOnMissingBean(
type = "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository")
@Lazy
public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
ObjectProvider<PasswordEncoder> passwordEncoder) {
SecurityProperties.User user = properties.getUser();
List<String> roles = user.getRoles();
return new InMemoryUserDetailsManager(
User.withUsername(user.getName()).password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
.roles(StringUtils.toStringArray(roles)).build());
}

private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {
String password = user.getPassword();
if (user.isPasswordGenerated()) {
logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
}
if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {
return password;
}
return NOOP_PASSWORD_PREFIX + password;
}

}

我们来简单解读一下该类,从@Conditional系列注解我们知道该类在类路径下存在AuthenticationManager、在Spring容器中存在BeanObjectPostProcessor并且不存在BeanAuthenticationManagerAuthenticationProviderUserDetailsService的情况下生效。千万不要纠结这些类干嘛用的该类只初始化了一个UserDetailsManager类型的Bean。UserDetailsManager类负责对安全用户实体抽象UserDetails的增删改查操作。同时还继承了UserDetailsService接口。

明白了上面这些让我们把目光再回到UserDetailsServiceAutoConfiguration上来。该类初始化了一个名为InMemoryUserDetailsManager的内存用户管理器。该管理器通过配置注入了一个默认的UserDetails存在内存中,就是我们上面用的那个user,每次启动user都是动态生成的。那么问题来了如果我们定义自己的UserDetailsManagerBean是不是就可以实现我们需要的用户管理逻辑呢?

3.4 自定义UserDetailsManager

我们来自定义一个UserDetailsManager来看看能不能达到自定义用户管理的效果。首先我们针对UserDetailsManager的所有方法进行一个代理实现,我们依然将用户存在内存中,区别就是这是我们自定义的:

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
package cn.felord.spring.security;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import java.util.HashMap;
import java.util.Map;

/**
* 代理 {@link org.springframework.security.provisioning.UserDetailsManager} 所有功能
*
* @author Felordcn
*/
public class UserDetailsRepository {

private Map<String, UserDetails> users = new HashMap<>();


public void createUser(UserDetails user) {
users.putIfAbsent(user.getUsername(), user);
}


public void updateUser(UserDetails user) {
users.put(user.getUsername(), user);
}


public void deleteUser(String username) {
users.remove(username);
}


public void changePassword(String oldPassword, String newPassword) {
Authentication currentUser = SecurityContextHolder.getContext()
.getAuthentication();

if (currentUser == null) {
// This would indicate bad coding somewhere
throw new AccessDeniedException(
"Can't change password as no Authentication object found in context "
+ "for current user.");
}

String username = currentUser.getName();

UserDetails user = users.get(username);


if (user == null) {
throw new IllegalStateException("Current user doesn't exist in database.");
}

// todo copy InMemoryUserDetailsManager 自行实现具体的更新密码逻辑
}


public boolean userExists(String username) {

return users.containsKey(username);
}


public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return users.get(username);
}
}

该类负责具体对UserDetails的增删改查操作。我们将其注入Spring容器:

1
2
3
4
5
6
7
8
9
@Bean
public UserDetailsRepository userDetailsRepository() {
UserDetailsRepository userDetailsRepository = new UserDetailsRepository();

// 为了让我们的登录能够运行 这里我们初始化一个用户Felordcn 密码采用明文 当你在密码12345上使用了前缀{noop} 意味着你的密码不使用加密,authorities 一定不能为空 这代表用户的角色权限集合
UserDetails felordcn = User.withUsername("Felordcn").password("{noop}12345").authorities(AuthorityUtils.NO_AUTHORITIES).build();
userDetailsRepository.createUser(felordcn);
return userDetailsRepository;
}

为了方便测试,我们也内置一个名称为Felordcn密码为12345UserDetails用户,密码采用明文。当你在密码12345上使用了前缀{noop}意味着你的密码不使用加密,这里我们并没有指定密码加密方式你可以使用PasswordEncoder来指定一种加密方式。通常推荐使用Bcrypt作为加密方式。默认Spring Security使用的也是此方式。authorities一定不能为null这代表用户的角色权限集合。接下来我们实现一个UserDetailsManager并注入Spring 容器:

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
@Bean
public UserDetailsManager userDetailsManager(UserDetailsRepository userDetailsRepository) {
return new UserDetailsManager() {
@Override
public void createUser(UserDetails user) {
userDetailsRepository.createUser(user);
}

@Override
public void updateUser(UserDetails user) {
userDetailsRepository.updateUser(user);
}

@Override
public void deleteUser(String username) {
userDetailsRepository.deleteUser(username);
}

@Override
public void changePassword(String oldPassword, String newPassword) {
userDetailsRepository.changePassword(oldPassword, newPassword);
}

@Override
public boolean userExists(String username) {
return userDetailsRepository.userExists(username);
}

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userDetailsRepository.loadUserByUsername(username);
}
};
}

这样实际执行委托给了UserDetailsRepository来做。我们重复章节3.的动作进入登录页面分别输入Felordcn12345成功进入。

3.5 数据库管理用户

经过以上的配置,相信聪明的你已经知道如何使用数据库来管理用户了。只需要将UserDetailsRepository中的users属性替代为抽象的Dao接口就行了,无论你使用JPA还是Mybatis来实现。

4. 总结

今天我们对Spring Security 中的用户信息 UserDetails 相关进行的一些解读。并自定义了用户信息处理服务。相信你已经对在Spring Security中如何加载用户信息,如何扩展用户信息有所掌握了源码地址,使用day01分支

转载自@felord.cn

pic

浏览器缓存是前端开发中不可避免的问题,对于web应用来说,它是提升页面性能同时减少服务器压力的利器。本文将简单地描述总结下浏览器缓存的知识和应用,希望对自己和大家都有所帮助

浏览器缓存类型

  • 强缓存
    不会向服务器发送请求,直接从缓存中读取资源,在chrome控制台的network选项中可以看到该请求返回200的状态码,并且size显示from disk cache或from memory cache

  • 协商缓存
    向服务器发送请求,服务器会根据这个请求的request header的一些参数来判断是否命中协商缓存,如果命中,则返回304状态码并带上新的response header通知浏览器从缓存中读取资源

两者的共同点是,都是从客户端缓存中读取资源;区别是强缓存不会发请求,协商缓存会发请求

缓存有关的header

强缓存

Expires:response header里的过期时间,浏览器再次加载资源时,如果在这个过期时间内,则命中强缓存

Cache-Control:当值设为max-age=300时,则代表在这个请求正确返回时间(浏览器也会记录下来)的5分钟内再次加载资源,就会命中强缓存

cache-control-expire.png

Expires和Cache-Control:max-age=*** 的作用是差不多的,区别就在于 Expires 是http1.0的产物,Cache-Control是http1.1的产物,两者同时存在的话,Cache-Control优先级高于Expires;在某些不支持HTTP1.1的环境下,Expires就会发挥用处。所以Expires其实是过时的产物,现阶段它的存在只是一种兼容性的写法

Expires和Cache-Control的区别还有一个:Expires是一个具体的服务器时间,这就导致一个问题,如果客户端时间和服务器时间相差较大,缓存命中与否就不是开发者所期望的。Cache-Control是一个时间段,控制就比较容易

协商缓存

ETag和If-None-Match:这两个要一起说。Etag是上一次加载资源时,服务器返回的response header,是对该资源的一种唯一标识,只要资源有变化,Etag就会重新生成。浏览器在下一次加载资源向服务器发送请求时,会将上一次返回的Etag值放到request header里的If-None-Match里,服务器接受到If-None-Match的值后,会拿来跟该资源文件的Etag值做比较,如果相同,则表示资源文件没有发生改变,命中协商缓存

Last-Modified和If-Modified-Since:这两个也要一起说。Last-Modified是该资源文件最后一次更改时间,服务器会在response header里返回,同时浏览器会将这个值保存起来,在下一次发送请求时,放到request header里的If-Modified-Since里,服务器在接收到后也会做比对,如果相同则命中协商缓存

response header

etag-lastmodified.png

request header

if-none-match.png

ETag和Last-Modified的作用和用法也是差不多,说一说他们的区别。
首先在精确度上,Etag要优于Last-Modified。Last-Modified的时间单位是秒,如果某个文件在1秒内改变了多次,那么他们的Last-Modified其实并没有体现出来修改,但是Etag每次都会改变确保了精度;如果是负载均衡的服务器,各个服务器生成的Last-Modified也有可能不一致。

第二在性能上,Etag要逊于Last-Modified,毕竟Last-Modified只需要记录时间,而Etag需要服务器通过算法来计算出一个hash值。
第三在优先级上,服务器校验优先考虑Etag

浏览器缓存过程

  1. 浏览器第一次加载资源,服务器返回200,浏览器将资源文件从服务器上请求下载下来,并把response header及该请求的返回时间一并缓存;

  2. 下一次加载资源时,先比较当前时间和上一次返回200时的时间差,如果没有超过cache-control设置的max-age,则没有过期,命中强缓存,不发请求直接从本地缓存读取该文件(如果浏览器不支持HTTP1.1,则用expires判断是否过期);如果时间过期,则向服务器发送header带有If-None-Match和If-Modified-Since的请求;

  3. 服务器收到请求后,优先根据Etag的值判断被请求的文件有没有做修改,Etag值一致则没有修改,命中协商缓存,返回304;如果不一致则有改动,直接返回新的资源文件带上新的Etag值并返回200;

  4. 如果服务器收到的请求没有Etag值,则将If-Modified-Since和被请求文件的最后修改时间做比对,一致则命中协商缓存,返回304;不一致则返回新的last-modified和文件并返回200;

用户行为对浏览器缓存的控制

  1. 地址栏访问,链接跳转是正常用户行为,将会触发浏览器缓存机制;
  2. F5刷新,浏览器会设置max-age=0,跳过强缓存判断,会进行协商缓存判断;
  3. ctrl+F5刷新,跳过强缓存和协商缓存,直接从服务器拉取资源。

原文链接

OAuth2有四种授权模式 授权码模式(authorization code) 简化模式(implicit) 密码模式(resouce owner password credentials) 客户端模式,具体理解OAuth2可以参考阮一峰文章

今天我们来实现OAuth2的密码模式

使用场景

我们在日常生活中经常会出现微信,微博等第三方登录的场景。我们在使用这些第三方登录时,不需要注册,直接授权登录即可,非常快捷方便。对于开发者来说,也不需要存储用户的用户名密码,只需要存储第三方平台的唯一标识即可

如果我们自己来实现这种第三方的服务作为认证中心,其他服务就可共用该认证中心,实现登录的功能。并且只要一次登录,便可在多个服务中自由通行

密码模式流程

密码模式的实现流程图如下:

  1. 用户向客户端提供用户名和密码
  2. 客户端将用户名和密码发送给认证服务器,向后者请求令牌
  3. 认证服务器确认无误后,向客户端提供访问令牌

bg2014051206-password.png

在微服务流行的今天,一个电商平台的背后可能是由多个服务构成,比如订单服务、用户服务等。要想用户登录之后可以访问任意微服务,一定需要携带一个凭证,来标识自己的身份。此处的凭证就是OAuth2中的access_token

实现

一 认证服务端

1.引入maven

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

2.配置application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
spring:
application:
name: oauth2server
datasource:
url: jdbc:mysql://localhost:3306/oauth2server?characterEncoding=UTF-8&useSSL=false
username: root
password: abc123
hikari:
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
maximum-pool-size: 9

server:
port: 8001

jwt:
signKey: wangweiye

3.spring security基础配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

// 允许匿名访问 oatuh 接口
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin().and().authorizeRequests().antMatchers(HttpMethod.POST, "/oauth/token").permitAll();
}
}

这个类的重点是声明PasswordEncoderAuthenticationManager两个Bean. PasswordEncoder是一个密码加密工具,可以实现不可逆的加密,AuthenticationManager是为了实现OAuth2的password模式必须指定的授权管理Bean

4.实现UserDetailsService

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
@Component
public class CustomUserDetailsService implements UserDetailsService {
private static Logger log = LoggerFactory.getLogger(CustomUserDetailsService.class);

@Autowired
private PasswordEncoder passwordEncoder;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info(String.format("username is: %s", username));

// 查询数据库操作
if (!username.equals("admin")) {
throw new UsernameNotFoundException("the user is not found");
} else {
// 用户角色也应在数据库中获取
String role = "ROLE_ADMIN";
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(role));

// 线上环境应该通过用户名查询数据库获取加密后的密码
String password = passwordEncoder.encode("123456");

// 返回自定义的CustomUserDetailService
User user = new User(username, password, authorities);
return user;
}
}
}

核心是loadUserByUsername方法,接收一个字符串用户名,然后返回一个UserDetails对象。在生产环境中,此处的用户名和密码都需要在数据库中查出,此处为了便于举例,写死为用户名admin,密码123456

5.OAuth2配置文件

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
@Configuration
@EnableAuthorizationServer
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
@Autowired
private TokenEnhancer jwtTokenEnhancer;

@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;

@Autowired
private TokenStore jwtTokenStore;

@Autowired
private UserDetailsService customUserDetailsService;

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private DataSource dataSource;

@Override
public void configure(final AuthorizationServerEndpointsConfigurer endpoints) {
// jwt增强模式
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> enhancerList = new ArrayList<>();
enhancerList.add(jwtTokenEnhancer);
enhancerList.add(jwtAccessTokenConverter);
enhancerChain.setTokenEnhancers(enhancerList);
endpoints.tokenStore(jwtTokenStore)
.userDetailsService(customUserDetailsService)
.authenticationManager(authenticationManager)
.tokenEnhancer(enhancerChain)
.accessTokenConverter(jwtAccessTokenConverter);
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}

@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients();
security.checkTokenAccess("isAuthenticated()");
security.tokenKeyAccess("isAuthenticated()");
}
}

此处重写了三个configure方法

AuthorizationServerEndpointsConfigurer参数的重写:

authenticationManage() 调用此方法才能支持 password 模式

userDetailsService() 设置用户验证服务

tokenStore() 指定token的存储方式

ClientDetailsServiceConfigurer参数的重写:

clients.jdbc(dataSource) 是指客户端的管理有dataSource数据库来管理。数据库脚本已放入文末地址中。其中authorized_grant_types列可以填写的内容有authorization_code: 授权码模式, implicit: 隐式授权类型, password: 资源所有者密码类型, client_credentials: 客户端凭据(客户端ID以及Key)类型, refresh_token: 通过以上授权获得的刷新令牌来获取新的令牌

6.增强JWT

如果需求需要在jwt中添加额外字段,可以使用TokenEnhancer增强器

1
2
3
4
5
6
7
8
9
public class JWTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
Map<String, Object> info = new HashMap<>();
info.put("jwt-ext", "JWT 扩展信息");
((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);
return oAuth2AccessToken;
}
}

二 客户端

pexels-disha-sheta-3489514.jpg

定义

Java反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性;这种动态获取信息以及动态调用对象方法的功能成为java语言的反射机制

反射涉及的类

Class类

代表类的实体,在运行的Java应用程序中表示类和接口

相关方法

方法 用途
forName(String className) 根据类名返回类的对象
getName() 获得类的完整路径名字
newInstance() 创建类的实例
getPackage() 获得类的名字
getSimpleName() 获得类的名字
getField(String name) 获得某个公有的属性对象
getFields() 获得所有公有的属性对象
getDeclaredField(String name) 获得某个属性对象
getDeclaredFields() 获得所有属性对象
getConstructor(Class…<?> parameterTypes) 获得该类中与参数类型匹配的公有构造方法
getConstructors() 获得该类的所有公有构造方法
getDeclaredConstructors() 获得该类所有构造方法
getMethod(String name, Class…<?> parameterTypes) 获得该类某个公有的方法
getMethods() 获得该类所有公有的方法
getDeclaredMethod(String name, Class…<?> parameterTypes) 获得该类某个方法
getDeclaredMethods() 获得该类所有方法
isInstance(Object obj) 如果obj是该类的实例则返回true

Field类

代表类的成员变量(属性)

相关方法

方法 用途
equals(Object obj) 属性与obj相等则返回true
get(Object obj) 获得ojb中对应的属性值
set(Object obj, Object value) 设置obj中对应属性值

Method类

代表类的方法

相关方法

方法 用途
invoke(Object obj, Object… args) 传递object对象及参数调用该对象对应的方法

Constructor类

代表类的构造方法

相关方法

方法 用途
newInstance(Object… initargs) 根据传递的参数创建类的对象

示例

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
package com.wang;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class Person {
private final static String classString = "com.wang.Person";

private String name;

private Integer age;

public Person() {
}

private Person(String name, Integer age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

// 创建实例
public static void reflectNewInstance() {
try {
Class<?> aClass = Class.forName(classString);

Object object = aClass.newInstance();
} catch (Exception e) {
e.printStackTrace();
}
}

// 反射私有的构造方法
public static void reflectPrivateConstructor() {
try {
Class<?> aClass = Class.forName(classString);
Constructor<?> declaredConstructor = aClass.getDeclaredConstructor(String.class, Integer.class);
declaredConstructor.setAccessible(true);
Object object = declaredConstructor.newInstance("wangweiye", 23);
System.out.println(object.toString());
} catch (Exception e) {
e.printStackTrace();
}
}

// 反射私有属性
public static void reflectPrivateField() {
try {
Class<?> aClass = Class.forName(classString);
Object object = aClass.newInstance();

Field field = aClass.getDeclaredField("classString");
field.setAccessible(true);
System.out.println(field.get(object));
} catch (Exception e) {
e.printStackTrace();
}
}

// 反射私有方法
public static void reflectPrivateMethod() {
try {
Class<?> aClass = Class.forName(classString);
Constructor<?> declaredConstructor = aClass.getDeclaredConstructor(String.class, Integer.class);
Object lbj = declaredConstructor.newInstance("lbj", 35);

Method method = aClass.getDeclaredMethod("getName");
method.setAccessible(true);

System.out.println(method.invoke(lbj).toString());
} catch (Exception e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
reflectNewInstance();

reflectPrivateConstructor();

reflectPrivateField();

reflectPrivateMethod();
}

@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}

在开发高并发系统时,有三把利器来保护系统:缓存 降级 限流

限流目的

限流的目的是通过对并发请求进行限速,一旦达到限制速率则可以拒绝服务或者排队等待等处理

限流算法

常用的限流算法有令牌桶算法漏桶算法

漏桶算法要求处理请求以一个恒定的速率,不能允许突发请求的快速处理,而令牌桶算法就比较适合

令牌桶算法的原理就是系统以恒定的速率产生令牌放入令牌桶中。令牌桶有容量,当满时,再放入的令牌就会被丢弃。当想处理一个请求的时候,需要从令牌桶中取出一个令牌,如果没有,则拒绝(非阻塞式)或者等待(阻塞式)

4179645397-5b6e4903ec371_articlex.png

Google开源项目Guava中RateLimiter使用的就是令牌桶算法,下面实例是使用自定义注解实现限制接口流量

定义一个注解

1
2
3
4
5
6
7
@Inherited
@Documented
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimitAspect {

}

定义切面

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
@Component
@Scope
@Aspect
public class RateLimitAop {
@Autowired(required = false)
private HttpServletResponse response;

private RateLimiter rateLimiter = RateLimiter.create(5); //比如说,我这里设置"并发数"为5

@Pointcut("@annotation(cc.wangweiye.ratelimit.RateLimitAspect)")
public void serviceLimit() {

}

@Around("serviceLimit()")
public Object around(ProceedingJoinPoint joinPoint) {
Boolean flag = rateLimiter.tryAcquire();
Object obj = "无返回";
try {
if (flag) {
obj = joinPoint.proceed();
} else {
String result = "failure";

output(response, result);
}
} catch (Throwable e) {
e.printStackTrace();
}
System.out.println("flag=" + flag + ",obj=" + obj);
return obj;
}

public void output(HttpServletResponse response, String msg) throws IOException {
response.setContentType("application/json;charset=UTF-8");
ServletOutputStream outputStream = null;
try {
outputStream = response.getOutputStream();
outputStream.write(msg.getBytes("UTF-8"));
} catch (IOException e) {
e.printStackTrace();
} finally {
outputStream.flush();
outputStream.close();
}
}
}

源码地址

apples-5543778_1280.jpg

floyd算法

弗洛伊德算法又称插点法是解决任意两点间的最短路径的一种算法

适用范围

边权可正可负,运行一次算法即可求得任意两点间的最短路径(无负权回路即可)

可以解决“多源最短路径”

什么是负权回路

图中1号点到3号点的最短路径是什么?

6.10.png

由于每次经过1->2->3这样的环,最短路径就会-1,所有永远找不到最短路径

实例

6.2-floyd.png

计算图中各个顶点到各个顶点的最短距离

首先我们定义一个二维数组来存储图中最易可见的点对点(不允许经过第三点)之间的距离,如果不能到达使用∞表示。另外规定自己到自己的距离是0,具体表示如下

6.3-.png

如果要使两点之间距离变短,唯一方法就是通过第三个点来解决。例如a点到b点的距离通过顶点k来中转那么a->k->b才有可能缩短a->b的距离。那么这个k点是哪个点呢?是否不只通过1个k点最短,而是经过两个或者更多点最短,比如a->k1->k2>b

比如图中4->3的距离是12,如果经过1点,那么4->1->3的距离就变为11。现在只允许经过1号点,求i点到j点的最短距离,只需判断e[i][1]+e[1][j]是否比e[i][j]要小。代码实现如下

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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import copy

# 创建一个4行4列的二维数组
m = [[0]*4 for i in range(4)]

# 为数组赋值
m[0][0] = 0
m[0][1] = 2
m[0][2] = 6
m[0][3] = 4
m[1][0] = 9999
m[1][1] = 0
m[1][2] = 3
m[1][3] = 9999
m[2][0] = 7
m[2][1] = 9999
m[2][2] = 0
m[2][3] = 1
m[3][0] = 5
m[3][1] = 9999
m[3][2] = 12
m[3][3] = 0

print('原路径数据:', m)

# 允许经过1号点的最短路径
n = copy.deepcopy(m)
i = 0
j = 0

for i in range(4):
for j in range(4):
if (n[i][j] > n[i][0] + n[0][j]):
n[i][j] = n[i][0] + n[0][j]

print('在允许经过1号点后的最短路径:', n)

执行结果如下:

原路径数据: [[0, 2, 6, 4], [9999, 0, 3, 9999], [7, 9999, 0, 1], [5, 9999, 12, 0]]

在允许经过1号点后的最短路径: [[0, 2, 6, 4], [9999, 0, 3, 9999], [7, 9, 0, 1], [5, 7, 11, 0]]

6.5-1.png

通过图中白色背景处我们可以看到通过1号点中转的情况下,3->2,4->2,4->3的路径都变短了

接下来求只允许1号和2号点的情况下任意两点间的最短距离,如何做呢?我们只需要在只经过1号点时任意两点最短距离的结果下,再判断经过2号顶点如何使i号点到j号点的路径边的更短

1
2
3
4
5
6
7
8
9
10
11
# 再允许经过2号点后的路径
o = copy.deepcopy(n)
i = 0
j = 0

for i in range(4):
for j in range(4):
if(o[i][j] > o[i][1] + o[1][j]):
o[i][j] = o[i][1] + o[1][j]

print('再允许经过2号点后的最短路径:', o)

执行结果如下:

再允许经过2号点后的最短路径: [[0, 2, 5, 4], [9999, 0, 3, 9999], [7, 9, 0, 1], [5, 7, 10, 0]]

6.6-1-2.png

同理继续在只允许1,2,3进行中转的情况下求最短距离。

6.7-1-2-3.png

最后在允许通过所有顶点作为中转,任意两点之间最终的最短路径为:

6.8-1-2-3-4.png

整个算法最终代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
k = 0
i = 0
j = 0

for k in range(4):
for i in range(4):
for j in range(4):
if (m[i][j] > m[i][k] + m[k][j]):
m[i][j] = m[i][k] + m[k][j]

print('最终路径:', m)

最终路径: [[0, 2, 5, 4], [9, 0, 3, 4], [6, 8, 0, 1], [5, 7, 10, 0]]

通过这种方法我们可以求出任意两个点之间最短路径。它的时间复杂度是 O(n3)

tree-5491570_1280.jpg

什么是Elasticsearch

Elasticsearch是一个开源的,分布式,高可用的数据库,是目前全文搜索的首选。它可以快速的存储、搜索、分析海量数据。

Elastic是基于开源库Lucene开发的,提供了REST API的操作接口,简单易用

优秀案例

GitHub使用ElasticSearch做PB级搜索;包括但不限于维基百科、携程等成功案例;

为什么ES全文搜索查询速度快

ES检索速度极快的很重要原理就是使用了倒排索引。什么是倒排索引?通常我们理解的索引就是通过key找到对应的value,所以通俗来讲倒排索引就是通过value找到对应的key

termindex.png

理解上图可以了解倒排索引的基本原理。首先ES索引时会先将文本进行分词,然后记录分词和文档的对应关系。当查询时,对查询条件同样进行分词,然后根据分词匹配对应的文档。

基本概念

Node和Cluster

Elastic 本质上是一个分布式数据库,允许多台服务器协同工作,每台服务器可以运行多个Elastic实例

单个 Elastic 实例称为一个节点(node)。一组节点构成一个集群(cluster)

Index

索引是具有某种相似特征文档的集合。类似于MySql中的Database

Type

es6.x建议在一个index中保持一个type

Document

文档是可以被索引的基本信息单元。文档用JSON表示。类似于MySql中的一条数据(Row)

Field

属性,类似于MySql中的某个字段(Column)

安装Elasticsearch(6.3.2)

自己安装,此处选择6.3.2版本

Kibana安装

Kibana 是为 Elasticsearch设计的开源分析和可视化平台。你可以使用 Kibana 来搜索,查看存储在 Elasticsearch 索引中的数据并与之交互。你可以很容易实现高级的数据分析和可视化,以图标的形式展现出来。

注意与安装的Elasticsearch版本号(6.3.2)一致

操作数据

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
# 新增数据1
PUT /person/_doc/1
{
"first_name": "John",
"last_name": "Smith",
"age": 25,
"about": "I love to go rock climbing",
"interests": [
"sports",
"music"
]
}

# 新增数据2
PUT /person/_doc/2
{
"first_name": "Eric",
"last_name": "Smith",
"age": 23,
"about": "I love basketball",
"interests": [
"sports",
"reading"
]
}

# 获得1
GET /person/_doc/1
# 获得2
GET /person/_doc/2

# bool搜索
POST /person/_search
{
"query": {
"bool": {
"should": [
{
"match": {
"first_name": "Eric"
}
}
]
}
}
}

# bool搜索
POST /person/_search
{
"query": {
"bool": {
"should": [
{
"match": {
"last_name": "Smith"
}
},
{
"match": {
"about": "basketball"
}
}
]
}
}
}

ik分词器

ik分词器是最流行的中文分词器,点击可查看ik和ES的版本对照关系以及安装方式

ik提供了两个分词算法ik_smartik_max_word

通过以下两个图,可以看出两个算法的区别。ik_smart为智能切分,ik_max_word为最细粒度划分

ik_smart

smart.png

ik_max_word

WX20200508-145012-max-word.png

根据两个算法的区别,一般遵循以下原则:

索引时,为了提供索引的覆盖范围,通常会采用ik_max_word分析器,会以最细粒度分词索引,搜索时为了提高搜索准确度,会采用ik_smart分析器,会以粗粒度分词

快照创建于恢复

使用无论哪个存储数据的软件,定期备份你的数据都是很重要的,es提供了快照机制。你可以使用snapshotAPI。这个会拿到你集群里当前的状态和数据然后保存到一个共享仓库里。这个备份过程是”智能”的。你的第一个快照会是一个数据的完整拷贝,但是所有后续的快照会保留的是已存快照和新数据之间的差异。随着你不时的对数据进行快照,备份也在增量的添加和删除。这意味着后续备份会相当快速,因为它们只传输很小的数据量。

创建仓库

1
2
3
4
5
6
7
 PUT _snapshot/my_backup ①
{
"type": "fs", ②
"settings": {
"location": "/mount/backups/my_backup" ③
}
}

① 给我们的仓库取一个名字,在本例它叫 my_backup。
② 我们指定仓库的类型应该是一个共享文件系统。
③ 最后,我们提供一个已挂载的设备作为目的地址。注意:共享文件系统路径必须确保集群所有节点都可以访问到。

创建快照

一个仓库可以包含多个快照。每个快照跟一系列索引相关(比如所有索引,一部分索引,或者单个索引)。当创建快照的时候,你指定你感兴趣的索引然后给快照取一个唯一的名字。

快照所有打开的索引

让我们从最基础的快照命令开始:

PUT _snapshot/my_backup/snapshot_1

这个会备份所有打开的索引到 my_backup 仓库下一个命名为 snapshot_1 的快照里。这个调用会立刻返回,然后快照会在后台运行。如果你想阻塞调用直到快照完成可以添加wait_for_completion标记实现

PUT _snapshot/my_backup/snapshot_1?wait_for_completion=true

快照指定索引

1
2
3
4
PUT _snapshot/my_backup/snapshot_2
{
"indices": "index_1,index_2"
}

这个快照命令现在只会备份index1index2了。

获取快照信息

获取某个仓库下所有快照信息

GET _snapshot/my_backup/_all

获取某个仓库下单个快照信息

GET _snapshot/my_backup/snapshot_2

响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"snapshots": [
{
"snapshot": "snapshot_202005080945",
"version_id": 2030599,
"version": "2.3.5",
"indices": [
"pipe"
],
"state": "SUCCESS",
"start_time": "2020-05-08T01:45:57.540Z",
"start_time_in_millis": 1588902357540,
"end_time": "2020-05-08T01:45:57.586Z",
"end_time_in_millis": 1588902357586,
"duration_in_millis": 46,
"failures": [],
"shards": {
"total": 1,
"failed": 0,
"successful": 1
}
}
]
}

删除快照

DELETE _snapshot/my_backup/snapshot_2

使用快照恢复数据

快照的目的就是为了备份,为了恢复。一旦你备份过了数据,恢复它就简单了:只要在你希望恢复回集群的快照 ID后面加上_restore即可

POST _snapshot/my_backup/snapshot_1/_restore

默认行为是把这个快照里存有的所有索引都恢复。如果snapshot_1包括五个索引,这五个都会被恢复到我们集群里。和snapshot`API 一样,我们也可以选择希望恢复具体哪个索引。

还有附加的选项用来重命名索引。这个选项允许你通过模式匹配索引名称,然后通过恢复进程提供一个新名称。如果你想在不替换现有数据的前提下,恢复老数据来验证内容,或者做其他处理,这个选项很有用。让我们从快照里恢复单个索引并提供一个替换的名称:

1
2
3
4
5
6
POST /_snapshot/my_backup/snapshot_1/_restore
{
"indices": "index_1", ①
"rename_pattern": "index_(.+)", ②
"rename_replacement": "restored_index_$1" ③
}

① 只恢复index_1索引,忽略快照中存在的其余索引。
② 查找所提供的模式能匹配上的正在恢复的索引。
③ 然后把它们重命名成替代的模式。

这个会恢复index_1到你及群里,但是重命名成了restored_index_1

grapes-5603367_1280.jpg

什么是websocket

Websocket是一种在单个TCP连接上进行全双工通信的协议

为什么需要websocket

HTTP协议有一个缺陷:通信只能由客户端发起

这种单向请求的特点导致如果服务端出现连续状态的变化,客户端要想获知就比较麻烦,我们只能使用”轮询”模式

轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。因此,工程师们一直在思考,有没有更好的方法。WebSocket 就是这样发明的。

bg2017051502.png

特点

  1. 建立在TCP协议之上,服务端的实现比较容易
  2. 与HTTP协议有良好的兼容。默认端口也是80和443,并且握手阶段采用HTTP协议,因此握手时不容易屏蔽,能通过各种HTTP代理服务器
  3. 数据格式比较轻,新能开销小,通信高效
  4. 可以发送文本,也可以发送二进制数据
  5. 没有同源限制,客户端可以与任意服务器通信
  6. 协议标识符是ws(如果加密,则为wss),服务器网址就是URL

实战

STOMP协议

STOMP即Simple Text Orientated Messaging Protocol,简单文本定向消息协议,它提供了一个可互操作的连接格式,允许STOMP客户端与任意STOMP消息代理(Broker)进行交互

后端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 表示客户端订阅地址的前缀信息,也就是客户端接收服务端消息的地址的前缀信息
config.enableSimpleBroker("/topic", "/user");

//指服务端接收地址的前缀,意思就是说客户端给服务端发消息的地址的前缀
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user/");
}

// 注册STOMP端点
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/my-websocket").setAllowedOrigins("*").withSockJS();
}
}
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
@Controller
@EnableScheduling
@SpringBootApplication
public class App {

public static void main(String[] args) {
SpringApplication.run(App.class, args);
}

@Autowired
private SimpMessagingTemplate messagingTemplate;

@GetMapping("/")
public String index() {
return "index";
}

@Scheduled(fixedRate = 1000)
public Object time() throws Exception {
// 发现消息
DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
messagingTemplate.convertAndSend("/topic/time", df.format(new Date()));
return "time";
}

@Scheduled(fixedRate = 2000)
@SendToUser("/greetings")
public String greeting2() {
messagingTemplate.convertAndSendToUser("2", "/greetings", "欢迎您,用户: 2");
return "OK";
}

@Scheduled(fixedRate = 2000)
@SendToUser("/greetings")
public String greeting1() {
messagingTemplate.convertAndSendToUser("1", "/greetings", "欢迎您,用户: 1");
return "OK";
}

@Scheduled(fixedRate = 9000)
public Object notification() {
// 发送消息
messagingTemplate.convertAndSend("/topic/notification", "hello world!");
return "ok";
}
}

前端

demo1和demo2都是订阅了两个topic。1个是获得服务器推送的时间,另一个是获得定向推送的消息(demo1向userId=1的用户推送,demo2向userId=2的用户推送)

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
<template>
<div id="app">
<div>
<label>WebSocket连接状态:</label>
<button type="button" :disabled="connected" @click="connect()">连接</button>
<button type="button" @click="disconnect()" :disabled="!connected">断开</button>
</div>

<div v-if="connected">
<label>当前服务器时间:{{ time }}</label>
<br />消息列表:
<br />
<hr />
<table>
<thead>
<tr>
<th>内容</th>
</tr>
</thead>
<tbody>
<tr v-for="row in lala">
<td>{{row}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

<script>
// @ is an alias to /src
export default {
name: "Demo1",
data() {
return {
stompClient: "",
// 连接状态
connected: false,
lala: [],
time: ""
};
},
methods: {
connect() {
const socket = new SockJS("http://localhost:8080/my-websocket");
this.stompClient = Stomp.over(socket);
const that = this
this.stompClient.connect({}, function(frame) {
// 注册发送消息(demo1和demo2区别在于此)
that.stompClient.subscribe("/user/1/greetings", function(msg) {
that.lala.push(msg);
});
// 注册推送时间回调
that.stompClient.subscribe("/topic/time", function(response) {
that.time = response.body;
});

that.connected = true;
});
},
disconnect() {
if (this.stompClient != null) {
this.stompClient.disconnect();
}

this.connected = false;
this.lala = [];
}
}
};
</script>

demo3是订阅后端topic,每隔一段时间推送消息给浏览器,随后浏览器显示Notification

并且该实例有自动重连的实现,心跳机制使用SockJS内部实现支持

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
<template>
<div id="app">
<div>服务器推送,客户端显示Notification</div>
<div>WebSocket连接状态:{{ connected }}</div>
<div>
<button type="button" :disabled="connected" @click="connect()">
连接
</button>
<button type="button" @click="disconnect()" :disabled="!connected">
断开
</button>
</div>
</div>
</template>

<script>
// @ is an alias to /src

export default {
name: "Demo3",
data() {
return {
stompClient: "",
// 连接状态
connected: false,
socketUrl: "http://localhost:8080/my-websocket",
// socketUrl: "http://back.mac.com:8888/finance-back-websocket",
lockReconnect: false,
}
},
methods: {
showNotification(info) {
// 弹窗
if (window.Notification) {
var popNotice = function () {
if (Notification.permission == "granted") {
var notification = new Notification("Hi,你好", {
body: info,
icon: "https://pcoss.guan18.com/%E6%A9%98%E8%92%9C.jpeg",
})

notification.onclick = function () {
notification.close()
}
}
}

if (Notification.permission == "granted") {
// 已经授权接受通知
popNotice()
} else if (Notification.permission != "denied") {
// 未拒绝接受通知,提示用户授权
Notification.requestPermission(function (permission) {
popNotice()
})
}
} else {
alert("浏览器不支持Notification")
}
},
successCallback() {
console.log('连接成功!')
this.connected = true
this.stompClient.subscribe("/topic/notification", (frame) => {
this.showNotification(frame.body)
})
},
reconnect() {
if(this.lockReconnect) {
return
}
this.lockReconnect = true
const reconTimeout = setTimeout(() => {
console.log('重连中...')
this.lockReconnect = false
this.socket = new SockJS(this.socketUrl)
this.stompClient = Stomp.over(this.socket)
this.stompClient.connect({}, (frame) => {
// 连接成功,清除定时器
clearTimeout(reconTimeout)
this.successCallback()
}, () => {
// 进行连接
this.connected = false
this.connect()
})
}, 5000)
},
connect() {
var socket = new SockJS(this.socketUrl)
this.stompClient = Stomp.over(socket)
this.stompClient.connect({}, this.successCallback, this.reconnect)
},
disconnect() {
if (this.stompClient != null) {
this.stompClient.disconnect()
}

this.connected = false
},
},
}
</script>

demo3演示
Kapture-2020-02-20-at-14.13.37.gif

前端源码

后端源码

问题

最近遇到个需求:前端登录后,后端返回tokenrefreshToken,当token过期时需要使用refreshToken去获取新的token和新的refreshToken,前端需要做到无感知刷新token

方法

利用 axios 的拦截器,拦截返回后的数据。当接口返回token过期后,先刷新token然后重试

难点

由于发起网络请求是异步的,当同时发起多个请求,而刷新 token 的接口还没有返回时,如何让这些请求等待刷新接口的返回,然后使用返回的新 token 重新发起请求呢?

实现

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
import axios from "axios";

// 创建一个axios实例
const instance = axios.create({
baseURL: "/api",
timeout: 300000,
headers: {
"Content-Type": "application/json",
"X-Token": getLocalToken() // headers塞token
}
});

// 从localStorage中获取token
function getLocalToken() {
const token = window.localStorage.getItem("token");
return token;
}

// 给实例添加一个setToken方法,用于登录后将最新token动态添加到header,同时将token保存在localStorage中
instance.setToken = token => {
instance.defaults.headers["X-Token"] = token;
window.localStorage.setItem("token", token);
};

function refreshToken() {
// instance是当前request.js中已创建的axios实例
return instance.post("/refreshtoken").then(res => res.data);
}

// 是否正在刷新的标记
let isRefreshing = false;
// 重试队列,每一项将是一个待执行的函数形式
let retryRequests = [];
// 请求后拦截 axios.interceptors.request.use()
instance.interceptors.response.use(
response => {
const { code } = response.data;
// 约定当code === 4001时,为token过期
if (code === 4001) {
// config是为请求提供的配置信息
const config = response.config;
if (!isRefreshing) {
isRefreshing = true;
return refreshToken().then(res => {
const { token } = res.data;
instance.setToken(token);
config.headers["X-Token"] = token;
// 注意: 原请求已经将baseURL进行拼接,此处不要重复拼接
config.baseURL = "";
// 将队列中的请求进行重试
retryRequests.forEach(cb => cb(token));
retryRequests = [];
return instance(config);
}).catch(res => {
console.error("refreshtoken error =>", res);
window.location.href = "/";
}).finally(() => {
// 保证下次刷新能够正常进入
isRefreshing = false;
});
} else {
// 正在刷新token,将返回一个未执行resolve的promise
return new Promise(resolve => {
// 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
retryRequests.push(token => {
config.baseURL = "";
config.headers["X-Token"] = token;
resolve(instance(config));
});
});
}
}
return response;
},
error => {
return Promise.reject(error);
}
);

export default instance;

函数防抖(debounce)

应用场景

在浏览器 DOM 事件里面,有一些事件会随着用户的操作不间断触发。比如:重新调整浏览器窗口大小(resize),浏览器页面滚动(scroll),鼠标移动(mousemove)。也就是说用户在触发这些浏览器操作的时候,如果脚本里面绑定了对应的事件处理方法,这个方法就不停的触发。

这并不是我们想要的,因为有的时候如果事件处理方法比较庞大,DOM 操作比如复杂,还不断的触发此类事件就会造成性能上的损失,导致用户体验下降(UI 反映慢、浏览器卡死等)。所以通常来讲我们会给相应事件添加延迟执行的逻辑。

一段时间内多次触发同一事件,只执行最后一次或者只执行第一次,中间的不执行

代码

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<title>函数防抖</title>
</head>

<body>
<div id="demo" style="height: 5000px"></div>
<script>
var COUNT = 0;
var demo = document.getElementById('demo');

function testFn() {
demo.innerHTML += 'testFn 被调用了 ' + ++COUNT + '次<br>';
}

// version0: 《JavaScript高级程序设计》中的方法,把定时器ID存为函数的一个属性
/*
function throttle(method, context) {
clearTimeout(method.tid);
method.tid = setTimeout(function () {
method.call(context);
}, 100);
}

window.onscroll = function () {
throttle(testFn);
}
*/

// version1: -> 错误 timer不是相对全局的变量每次scroll会生成一个timer
/*
window.onscroll = function () {
var timer = null;
clearTimeout(timer);

timer = setTimeout(function () {
testFn();
}, 100);
};
*/

// version2: -> 正确, 但是会多添加一个相对全局的变量,有可能影响业务逻辑
/*
var timer = null;
window.onscroll = function () {
clearTimeout(timer);
timer = setTimeout(function() {
testFn();
}, 100);
};
*/

// version3: -> 正确,使用闭包
/**
* 函数节流方法
* @param Function fn 延时调用函数
* @param Number delay 延迟多长时间
* @return Function 延迟执行的方法
*/

/*
var throttle = function (fn, delay) {
var timer = null;

return function () {
clearTimeout(timer);
timer = setTimeout(function () {
fn();
}, delay);
}
};
*/

// 第一种调用方式
/*
var f = throttle(testFn, 200);
window.onscroll = function () {
f();
};
*/

// 第二种调用方式
/* window.onscroll = throttle(testFn, 200);*/
</script>
</body>

</html>

函数节流(throttle)

指连续触发事件但是在n秒中只执行一次函数。即2n秒内执行 2 次… 节流如字面意思,会稀释函数的执行频率

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<title>函数节流</title>
</head>

<body>
<div id="demo" style="height: 5000px"></div>
<script>
var COUNT = 0;
var demo = document.getElementById('demo');

function testFn() {
demo.innerHTML += 'testFn 被调用了 ' + ++COUNT + '次<br>';
}

// versin4:最终模式
var throttle = function (fn, delay, atleast) {
var timer = null;
var previous = null;

return function () {
var now = +new Date();

if (!previous) previous = now;

if (atleast && now - previous > atleast) {
fn();
// 重置上一次开始时间为本次结束时间
previous = now;
clearTimeout(timer);
} else {
clearTimeout(timer);
timer = setTimeout(function () {
fn();
previous = null;
}, delay);
}
}
};

// atleast参数选填
window.onscroll = throttle(testFn, 200, 1000);
// window.onscroll = throttle(testFn, 200);
</script>
</body>

</html>

完整代码

0%