欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

徒手撸springcloud+VUE--Oauth2篇

程序员文章站 2024-02-03 19:12:04
...

上一篇写了父工程和eureka、gateway服务的搭建和配置,基本上采用默认的配置就可完成基本的搭建,比较简单。本篇重点写点Oauth2的搭建和踩过的各种坑

一、引入相关jar包

1、oauth2的jar包和自动化配置的jar包

<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
    <version>2.3.4.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.security.oauth.boot</groupId>
    <artifactId>spring-security-oauth2-autoconfigure</artifactId>
    <version>2.1.2.RELEASE</version>

2、引入eureka客户端jar包

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

3、本例采用数据库存储token和客户端信息,所以需要引入数据库相关jar包

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.35</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.23</version>
</dependency>
<dependency>
    <groupId>tk.mybatis</groupId>
    <artifactId>mapper-spring-boot-starter</artifactId>
    <version>2.1.5</version>
</dependency>

本例采用的是tk.mybatis,这个jar包基本包含了常用的增删改查功能,而且很方便和Page插件结合,一行代码实现分页查询,感兴趣的小伙伴可以去通用mapper详细了解。
4、本例使用openfeign实现各服务之间互相通信,所以需要引入相应jar包

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
 <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<dependency>
    <groupId>com.land</groupId>
    <artifactId>util</artifactId>
    <version>1.0</version>
    <scope>compile</scope>
</dependency>

使用缓存来控制token的刷新,使用util服务中的一些通用类满足各服务之间的用户数据,所以也需要相应的引入其jar包
至此相应jar包都已经加入,后续其他的jar包在需要使用时加入。

二、配置WebSecurityConfigurerAdapter

1、新建WebSecurityConfig类继承WebSecurityConfigurerAdapter,代码如下

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserServiceDetail userServiceDetail;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //禁用跨站伪造
        http
                .authorizeRequests().anyRequest().authenticated();
    }
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .userDetailsService(userServiceDetail)
                .passwordEncoder(new BCryptPasswordEncoder());
    }
}

配置所有的访问都必须经过验证,并设置了获取用户的service和密码验证方式。
其中UserServiceDetail为验证用户的方法,需要继承UserDetailsService,代码如下

@Service
public class UserServiceDetail implements UserDetailsService {
    @Autowired
    private SysFeignService sysFeignService;

    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        UserDto userDto = sysFeignService.loadUserByUsername(userName);
        if(null != userDto){
            User user = new User(userDto);
            return user;
        }else {
            throw new UsernameNotFoundException("账号不存在");
        }
    }
}

此处采用openfeign的方式访问远程sys服务,获取用户的相关信息,代码如下

@FeignClient(name = "sys",configuration = FeignConfig.class)
public interface SysFeignService {
    @GetMapping(value = "/common/loadUserByUsername")
    UserDto loadUserByUsername(@RequestParam("userName") String userName);

    @GetMapping(value = "/common/findByName")
    UserDto findByName(@RequestParam("userName") String userName);

}

两个获取方式,其中一个是获取管理员的,一个是获取普通用户的,稍后再详细说明
使用@FeignClient注解启用远程访问,其中name参数指远程服务的名称,configuration指配置文件,我的配置文件采用的是默认的,代码如下

@Configuration
public class FeignConfig {
    @Bean
    public Retryer feignRetryer(){
        return new Retryer.Default();
    }
}

三、配置AuthorizationServerConfigurerAdapter

新建Oauth2Config继承AuthorizationServerConfigurerAdapter
1、创建tokenStore方法,并注册为bean

@Bean
public TokenStore tokenStore(){
    //使用数据库存储token信息
    return new JdbcTokenStore(dataSource);
//  return new JwtTokenStore(jwtAccessTokenConverter());
}

tokenStore有4种方式存储,分别是内存、数据库、jwt和Redis,具体可打开TokenStore源码,点击其实现类查看,如下图
徒手撸springcloud+VUE--Oauth2篇
本例采用数据的存储方式,主要是为了后期能在管理端直接控制用户token
2、配置客户端存储方式,代码如下

@Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        //使用数据库存储客户端信息
        clients.jdbc(dataSource);
    }

客户端的存储方式有两种,数据库和内存存储,可见下方源码

public class ClientDetailsServiceConfigurer extends SecurityConfigurerAdapter<ClientDetailsService, ClientDetailsServiceBuilder<?>> {
    public ClientDetailsServiceConfigurer(ClientDetailsServiceBuilder<?> builder) {
        this.setBuilder(builder);
    }

    public ClientDetailsServiceBuilder<?> withClientDetails(ClientDetailsService clientDetailsService) throws Exception {
        this.setBuilder(((ClientDetailsServiceBuilder)this.getBuilder()).clients(clientDetailsService));
        return (ClientDetailsServiceBuilder)this.and();
    }

    public InMemoryClientDetailsServiceBuilder inMemory() throws Exception {
        InMemoryClientDetailsServiceBuilder next = ((ClientDetailsServiceBuilder)this.getBuilder()).inMemory();
        this.setBuilder(next);
        return next;
    }

    public JdbcClientDetailsServiceBuilder jdbc(DataSource dataSource) throws Exception {
        JdbcClientDetailsServiceBuilder next = ((ClientDetailsServiceBuilder)this.getBuilder()).jdbc().dataSource(dataSource);
        this.setBuilder(next);
        return next;
    }

    public void init(ClientDetailsServiceBuilder<?> builder) throws Exception {
    }

    public void configure(ClientDetailsServiceBuilder<?> builder) throws Exception {
    }
}

本例采用的同样是数据库方式,原因也是为了能动态的控制各客户端的访问权限,例如账号密码、资源ID、scope和token有效期等。但需要配合token管理,因为修改客户端信息后不会马上生效,已获取token的客户端已经将相关信息存放到token中,在token失效之前不会再访问数据库进行验证。因此如果想修改立马生效,可以将已生成的token全部清除。
3、配置获取token的策略

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

4、配置AuthorizationServerEndpointsConfigurer

@Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(tokenStore()).authenticationManager(authenticationManager);
        endpoints.userDetailsService(userServiceDetail);
    }

四、扩展自定义登录方式

1、在配置AuthorizationServerEndpointsConfigurer中设置tokenGranter为自定义tokenGranter

endpoints.tokenGranter(tokenGranter(endpoints));

2、自定义tokenGranter方法

private TokenGranter tokenGranter(AuthorizationServerEndpointsConfigurer endpoints) {
        TokenGranter tokenGranter = new TokenGranter() {
            private CompositeTokenGranter delegate;

            @Override
            public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
                if (delegate == null) {
                    delegate = new CompositeTokenGranter(getDefaultTokenGranters(endpoints));
                }
                return delegate.grant(grantType, tokenRequest);
            }
        };
        return tokenGranter;
    }

通过getDefaultTokenGranters方法重写默认TokenGranter
3、自定义getDefaultTokenGranters方法

private List<TokenGranter> getDefaultTokenGranters(AuthorizationServerEndpointsConfigurer endpoints) {
        AuthorizationServerTokenServices tokenService = endpoints.getTokenServices();
        OAuth2RequestFactory requestFactory = endpoints.getOAuth2RequestFactory();
        AuthorizationCodeServices authorizationCodeServices = endpoints.getAuthorizationCodeServices();

        List<TokenGranter> tokenGranters = new ArrayList<>();
        tokenGranters.add(new ClientCredentialsTokenGranter(tokenService, clientDetailsService, requestFactory));
        tokenGranters.add(new RefreshTokenGranter(tokenService, clientDetailsService, requestFactory));
        tokenGranters.add(new AuthorizationCodeTokenGranter(tokenService, authorizationCodeServices, clientDetailsService, requestFactory));
        tokenGranters.add(new ImplicitTokenGranter(tokenService, clientDetailsService, requestFactory));
        tokenGranters.add(new ResourceOwnerPasswordTokenGranter(authenticationManager, tokenService, clientDetailsService, requestFactory));

        //短信验证码授权
        tokenGranters.add(new SmsCodeTokenGranter(tokenService, clientDetailsService, requestFactory,sysFeignService));
        //管理用户账号密码登录
        tokenGranters.add(new PasswordTokenGranter(authenticationManager,tokenService, clientDetailsService, requestFactory,sysFeignService));
        //普通用户账号密码登录
        tokenGranters.add(new UserPasswordTokenGranter(authenticationManager,tokenService, clientDetailsService, requestFactory,sysFeignService));
        return tokenGranters;
    }

首先在tokenGranters 列表中加入默认的tokenGranter,包括客户端认证、token刷新等,在下方加入自定义的登录方法。
4、自定义登录方式
以管理员账号登录为例,继承AbstractTokenGranter ,代码如下

public class PasswordTokenGranter extends AbstractTokenGranter {
    private static final String GRANT_TYPE = "sys_password";
    private final AuthenticationManager authenticationManager;
    private SysFeignService sysFeignService;


    protected PasswordTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, SysFeignService sysFeignService) {
        super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
        this.sysFeignService = sysFeignService;
        this.authenticationManager = authenticationManager;
    }

    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
        Map<String, String> parameters = new LinkedHashMap(tokenRequest.getRequestParameters());
        String username = parameters.get("username");
        String password = parameters.get("password");

        UserDto userDto = sysFeignService.loadUserByUsername(username);
        if(userDto == null) {
            throw new InvalidGrantException("用户不存在");
        }
        if(!new BCryptPasswordEncoder().matches(password,userDto.getPassword())){
            throw new InvalidGrantException("密码不正确");
        }
        User user = new User(userDto);
        Authentication userAuth = new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities());
        ((AbstractAuthenticationToken)userAuth).setDetails(userDto);

        OAuth2Request storedOAuth2Request = this.getRequestFactory().createOAuth2Request(client, tokenRequest);
        return new OAuth2Authentication(storedOAuth2Request, userAuth);
    }

}

自定义GRANT_TYPE 为sys_password,即在登录时GRANT_TYPE 参数为sys_password时会自动进入到此方法进行登录处理
重写getOAuth2Authentication方法,进行登录逻辑处理
这里有三个坑,一个是密码验证方式,一个是bean注入方式,一个是客户端只能获取用户名,其他信息无法获取

  • 密码验证方式

密码验证方式和常规的MD5验证方式不同,只能使用BCryptPasswordEncoder内置的matches方法进行验证,因为BCryptPasswordEncoder是不对称加密,同样的明文每次加密后的密文都是不同的。这个问题是对BCryptPasswordEncoder的不了解,坑了我好长时间,最后发现登陆传的密码和数据库密码不一致,然后自己再次加密的时候,发现还是跟数据库不同,才找到问题所在,说出来有点丢人。。。

  • bean注入方式
    必须在Oauth2Config类中注入,然后在自定义方法中将bean传到这里,然后初始化的时候赋值,否则bean会一直为null,具体原因未知。在这里被坑了整整一天。。。。
    1、在Oauth2Config中注入sysFeignService
@Autowired
private SysFeignService sysFeignService;

2、在自定义方法中将bean传过去

tokenGranters.add(new PasswordTokenGranter(authenticationManager,tokenService, clientDetailsService, requestFactory,sysFeignService));

2、在PasswordTokenGranter初始化方法中赋值

private final AuthenticationManager authenticationManager;
    private SysFeignService sysFeignService;


    protected PasswordTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, SysFeignService sysFeignService) {
    super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
    this.sysFeignService = sysFeignService;
    this.authenticationManager = authenticationManager;
}

3、扩展客户端获取用户信息

User user = new User(userDto);
Authentication userAuth = new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities());
((AbstractAuthenticationToken)userAuth).setDetails(userDto);

其中User继承了security的UserDetails,通过user进行验证方便后期扩展,不过这是我的强迫症,实际上通过userDto也可以。然后通过setDetails方法将用户信息放到token中,在资源服务中就可以通过下面的方法获取userDto数据

public static UserDto getUser(){
    try {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        JSONObject authJson = JSONObject.parseObject(JSONObject.toJSONString(authentication));
        JSONObject userAuthJson = authJson.getJSONObject("userAuthentication");
        JSONObject detailsJson = userAuthJson.getJSONObject("details");
        JSONObject principanJson = detailsJson.getJSONObject("principal");
        UserDto userDto = new UserDto();
        userDto.setUsername(principanJson.getString("username"));
        userDto.setUserType(principanJson.getString("userType"));
        userDto.setEnabled(principanJson.getBoolean("enabled"));
        userDto.setAuthList(principanJson.getJSONArray("authorities").toJavaList(String.class));
        userDto.setBirthday(principanJson.getDate("birthday"));
        userDto.setSex(principanJson.getString("sex"));
        userDto.setMail(principanJson.getString("mail"));
        userDto.setRealName(principanJson.getString("realName"));
        userDto.setNickName(principanJson.getString("nickName"));
        userDto.setId(principanJson.getInteger("id"));
        userDto.setPhone(principanJson.getString("phone"));
        return userDto;
    }catch (Exception e){
        return null;
    }
}

当然也可以在controller中直接将Principal 、 OauthApplication 、 Authentication 对象传进去直接获取,我这里因为使用自定义注解实现日志服务,这几个对象解析是会报错,所以就写了一个工具类来获取,后文在介绍sys服务时会说。

五、token自动刷新

客户端信息已经配置了token的有效期,本例采用数据存储,数据库必须通过官方的语句进行生成,数据语句可以在官网和网上获取
数据库结构如下图
徒手撸springcloud+VUE--Oauth2篇
本例只使用oauth_client_details,oauth_access_token两张表

oauth_access_token表如下
徒手撸springcloud+VUE--Oauth2篇
oauth_client_details表如下
徒手撸springcloud+VUE--Oauth2篇
其中access_token_validity即是token有效期
但是token到期后还得引导用户重新进行登录,体验非常不好,如果能像session一样多长时间不访问才会过期就好了,基于这个想法,进行了各种实验,最终找到一种方法,具体做法如下
1、自定义OauthTokenInterceptor拦截器继承HandlerInterceptorAdapter

@Component
public class OauthTokenInterceptor extends HandlerInterceptorAdapter {
    @Autowired
    private TokenStore tokenStore;

//    @Autowired
//    private RedisTemplate<String,String> redisTemplate;

    private final Cache cache = Cache.newHardMemoryCache(0,3*60);

    private final long hour = 2;

    //拦截请求,如果存在token,自动重置token有效期
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String authorization = request.getHeader("Authorization");
        if(StringUtils.isNotBlank(authorization)){
            authorization = authorization.substring(authorization.indexOf("Bearer")+6).trim();
            DefaultOAuth2AccessToken defaultOAuth2AccessToken = (DefaultOAuth2AccessToken) tokenStore.readAccessToken(authorization);
            if(!Objects.isNull(defaultOAuth2AccessToken) && defaultOAuth2AccessToken.isExpired()){
                //token已过期,返回
                return super.preHandle(request,response,handler);
            }
            if(null == cache.get(authorization)){
                //将token放到缓存中,有效期设置为3分钟,即3分钟之刷新一次
                cache.put(authorization,new Date());
                System.out.println("============================更新token:"+authorization+"("+new Date()+")===============================");
                //获取到期时间
                Date expiration = defaultOAuth2AccessToken.getExpiration();
                //给到期时间重置为两个小时
                expiration.setTime(new Date().getTime() + hour*60*60*1000L);
                defaultOAuth2AccessToken.setExpiration(expiration);
                //重新设置token
                OAuth2Authentication oauthApplication = tokenStore.readAuthentication(defaultOAuth2AccessToken);
                tokenStore.storeAccessToken(defaultOAuth2AccessToken,oauthApplication);
            }
        }
        return super.preHandle(request,response,handler);
    }
}

重写preHandle方法
首先判断header中是否有token,header中的token是放在Authorization参数中,这个前后端要统一,token前面会加上token类型Bearer,这里也要前后端统一,然后进行截取,获取token
如果没有token直接放行
如果有token,判断token是否过期,如果过期直接放行
如果token未过期,解析token,并重新设置token有效期,本例每次都将token有效期重置为两个小时,实际情况可以根据业务进行调整
这里有个坑,就是重新设置token的时候会报错,原因有以下几个

  • 重新设置token,会先删除原token,然后在插入新的token,源码如下
public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
        String refreshToken = null;
        if (token.getRefreshToken() != null) {
            refreshToken = token.getRefreshToken().getValue();
        }

        if (this.readAccessToken(token.getValue()) != null) {
            this.removeAccessToken(token.getValue());
        }

        this.jdbcTemplate.update(this.insertAccessTokenSql, new Object[]{this.extractTokenKey(token.getValue()), new SqlLobValue(this.serializeAccessToken(token)), this.authenticationKeyGenerator.extractKey(authentication), authentication.isClientOnly() ? null : authentication.getName(), authentication.getOAuth2Request().getClientId(), new SqlLobValue(this.serializeAuthentication(authentication)), this.extractTokenKey(refreshToken)}, new int[]{12, 2004, 12, 12, 12, 2004, 12});
    }

insertAccessTokenSql 语句

private String insertAccessTokenSql = "insert into oauth_access_token (token_id, token, authentication_id, user_name, client_id, authentication, refresh_token) values (?, ?, ?, ?, ?, ?, ?)";

removeAccessToken方法

public void removeAccessToken(String tokenValue) {
        this.jdbcTemplate.update(this.deleteAccessTokenSql, new Object[]{this.extractTokenKey(tokenValue)});
    }

deleteAccessTokenSql语句

private String deleteAccessTokenSql = "delete from oauth_access_token where token_id = ?";

这里删除的时候是直接将token删除了,但是如果同时使用refreshToken 的话会造成oauth_refresh_token表外键找不到
oauth_refresh_token表结构如下
徒手撸springcloud+VUE--Oauth2篇
所以需要放弃使用refreshToken (自认为,不知道对不对,结果就是我不使用refreshToken 后这个问题解决了。。。)

  • 频繁刷新脏数据问题
    上面的代码中,各位小伙伴可以看到里面有一个判断缓存中是否有token的步骤,主要就是解决这个问题的。原因是我登录的时候需要获取token,获取当前登录用户,获取菜单、权限等等,所以会在登录的一瞬间多次访问,导致解析的时候token已经不存在了(原来的已经删除,新的还未生成,导致token解析失败)
    为了解决这个问题,刚开始是采用Redis存储的方式,每次刷新token之后将token存放到Redis中,每次更新前先判断Redis中是否已经存在,如果存在就不再更新了。首先考虑Redis的原因是可以通过Redis客户端控制刷新,而且Redis支持自定义有效期,这样才能控制token的刷新频率。
  • Redis读取太慢
    使用Redis之后上述的问题得到了改善,但是偶尔还是会出问题,而且并发高峰期出问题的频率比较高,问题个人怀疑是Redis读取速度的问题(我的Redis是放在外网服务器上的,个人在本地调试,存在一定的网络时间),所以将Redis更换为cache缓存,至此个人测试阶段未再出现这个问题。
    不过这个解决办法并不是最好的,因为如果要实现oauth服务高可用如果设置全局,这个问题留待以后有时间再考虑。

2、将自定义拦截器加入到SpringMVC拦截器中
新建WebMvcConfig类继承WebMvcConfigurer

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private OauthTokenInterceptor oauthTokenInterceptor;

    //添加自定义拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(oauthTokenInterceptor).addPathPatterns("/**");
    }
}

这里踩了个坑,自定义的OauthTokenInterceptor 拦截器需要用注入bean的方式使用,不能用new的方式,否则OauthTokenInterceptor 类会报空指针。网上看的一些方法直接new就可以用,我的不可以,具体原因未深入探究。

六、配置文件

所有的配置如下:

server:
  port: 8768

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka

spring:
  application:
    name: oauth
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/longmao-oauth?serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8
    username: root
    password: root
    # 连接池配置
    druid:
      # 初始化大小,最小,最大
      initial-size: 5
      min-idle: 5
      max-active: 20
      # 配置获取连接等待超时的时间
      max-wait: 60000
      # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位毫秒
      time-between-eviction-runs-millis: 60000
      # 配置一个连接在池中最小生存时间
      min-evictable-idle-time-millis: 300000
      validation-query: SELECT 1 FROM sys_user
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false
      # 打开 PSCache,并且指定每个连接上 PSCache 的大小
      pool-prepared-statements: true
      max-pool-prepared-statement-per-connection-size: 20
      # 配置监控统计拦截的 Filter,去掉后监控界面 SQL 无法统计,wall 用于防火墙
      filters: stat,wall
      # 通过 connection-properties 属性打开 mergeSql 功能;慢 SQL 记录
      connection-properties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
      # 配置 DruidStatFilter
      web-stat-filter:
        enabled: true
        url-pattern: /*
        exclusions: .js,*.gif,*.jpg,*.bmp,*.png,*.css,*.ico,/druid/*
      # 配置 DruidStatViewServlet
      stat-view-servlet:
        enabled: true
        url-pattern: /druid/*
        # IP 白名单,没有配置或者为空,则允许所有访问
        #        allow: 127.0.0.1
        # IP 黑名单,若白名单也存在,则优先使用
        #        deny: 192.168.31.253
        # 禁用 HTML 中 Reset All 按钮
        reset-enable: false
        # 登录用户名/密码
        login-username: root
        login-password: 123
  redis:
    database: 0
    host: localhost
    password: test123
    port: 6379
    timeout: 1000

七、总结

oauth2对我来说还是个新东西,刚开始写的时候下手太急了,导致后续进行了多次更改。对于新技术最好还是通过几个步骤来学习会更好些

  1. 通过网上查询相关资料,了解技术背景、主要解决方向和实现逻辑
  2. 查询相关案例,读代码
  3. 查看源码,了解实现的具体方法
  4. 下手尝试,深入理解

以上代码都可以直接拿来用,已经经过个人测试,基本解决了我现在的所有需求,有问题可以留言讨论。