一、思路
shiro用来认证用户及权限控制,jwt用来生成一个token,暂存用户信息。
为什么不使用session而使用jwt?传统情况下是只有一个服务器,用户登陆后将一些信息以session的形式存储服务器上,
然后将sessionid存储在本地cookie中,当用户下次请求时将会将sessionid传递给服务器,用于确认身份。
但如果是分布式的情况下会出现问题,在服务器集群中,需要一个session数据库来存储每一个session,提供给集群中所有服务使用,且无法跨域(多个Ip)使用。
而jwt是生成一个token存储在客户端,每次请求将其存储在header中,解决了跨域,且可以通过自定义的方法进行验证,解决了分布式验证的问题。
缺点:无法在服务器注销、比sessionid大占带宽、一次性(想修改里面的内容,就必须签发一个新的jwt)
二、废话不多说上代码
- pom.xml
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.2</version>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- 工具 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.2.3</version>
</dependency>
<!-- 密码加密 -->
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.62</version>
</dependency>
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.1</version>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 数据源 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.21</version>
</dependency>
<!-- xss过滤组件 -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.9.2</version>
</dependency>
<!-- restful api 文档 swagger2 -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>swagger-bootstrap-ui</artifactId>
<version>1.9.3</version>
</dependency>
- 重构token生成继承
AuthenticationToken
类
package com.luoyx.vjsb.authority.token;
import lombok.Data;
import org.apache.shiro.authc.AuthenticationToken;
/**
* <p>
* 自定义token
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/19 17:06
*/
@Data
public class Oauth2Token implements AuthenticationToken {
private static final long serialVersionUID = 8585428037102822625L;
/**
* json web token值
*/
private String jwt;
public Oauth2Token(String jwt) {
this.jwt = jwt;
}
/**
* jwt
*
* @return jwt
*/
@Override
public Object getPrincipal() {
return this.jwt;
}
/**
* 返回jwt
*
* @return jwt
*/
@Override
public Object getCredentials() {
return this.jwt;
}
}
- 自定义过滤器,继承
AuthenticatingFilter
类
package com.luoyx.vjsb.authority.shiro.filter;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.alibaba.fastjson.JSON;
import com.luoyx.vjsb.authority.token.Oauth2Token;
import com.luoyx.vjsb.authority.util.JsonWebTokenUtil;
import com.luoyx.vjsb.authority.vo.JwtAccount;
import com.luoyx.vjsb.common.properties.VjsbProperties;
import com.luoyx.vjsb.common.util.AjaxResult;
import com.luoyx.vjsb.common.util.IpUtil;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* <p>
* 自定义过滤器配置
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/19 17:34
*/
@Slf4j
@Setter
public class Oauth2Filter extends AuthenticatingFilter {
private final String expiredJwt = "expiredJwt";
private StringRedisTemplate redisTemplate;
private VjsbProperties properties;
/**
* 对跨域提供支持
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
/**
* 先执行:isAccessAllowed 再执行onAccessDenied
* 如果返回true的话,就直接返回交给下一个filter进行处理。
* 如果返回false的话,回往下执行onAccessDenied
*
* @param request the incoming <code>ServletRequest</code>
* @param response the outgoing <code>ServletResponse</code>
* @param mappedValue the filter-specific config value mapped to this filter in the URL rules mappings.
* @return <code>true</code> if the request should proceed through the filter normally, <code>false</code> if the
* request should be processed by this filter's
* {@link #onAccessDenied(ServletRequest, ServletResponse, Object)} method instead.
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
return ((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name());
}
/**
* onAccessDenied:表示当访问拒绝时是否已经处理了;如果返回true表示需要继续处理;
* 如果返回false表示该拦截器实例已经处理了,将直接返回即可。
*
* @param request the incoming <code>ServletRequest</code>
* @param response the outgoing <code>ServletResponse</code>
* @return <code>true</code> if the request should continue to be processed; false if the subclass will
* handle/render the response directly.
* @throws Exception if there is an error processing the request.
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
String token = getRequestToken((HttpServletRequest) request);
if (StrUtil.isBlank(token)) {
AjaxResult.responseWrite(JSON.toJSONString(AjaxResult.success("无权限访问", 1007, null)), response);
return false;
}
return executeLogin(request, response);
}
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
// 这个创建token是在登录完成之后,去调用控制层时调用的,也就是要有token的时候,才会调用这个方法
return new Oauth2Token(getRequestToken(WebUtils.toHttp(request)));
}
/**
* 获取请求的token
*/
private String getRequestToken(HttpServletRequest httpRequest) {
//从header中获取token
String token = httpRequest.getHeader("Authorization");
//如果header中不存在token,则从参数中获取token
if (StrUtil.isBlank(token)) {
token = httpRequest.getParameter("Authorization");
}
return token;
}
/**
* 登录失败处理
*
* @param token token
* @param e 异常
* @param request 1
* @param response 2
* @return boolean
*/
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
//处理登录失败的异常
Throwable throwable = e.getCause() == null ? e : e.getCause();
PrintWriter writer = null;
try {
writer = WebUtils.toHttp(response).getWriter();
} catch (IOException ignored) {
}
assert writer !=null;
// 这里做token验证处理,在验证器中验证token
String message = e.getMessage();
// 令牌过期
if (expiredJwt.equals(message)) {
String jwt = JsonWebTokenUtil.parseJwtPayload(token.getCredentials().toString());
JwtAccount jwtAccount = JSONUtil.toBean(jwt, JwtAccount.class);
String s = redisTemplate.opsForValue().get("JWT-SESSION-" + IpUtil.getIpFromRequest((HttpServletRequest) request).toUpperCase() + jwtAccount.getSub());
if (s != null) {
// 重新申请新的JWT
String newJwt = JsonWebTokenUtil.createToken(UUID.randomUUID().toString(), jwtAccount.getSub(),
"token-server", jwtAccount.getPassword(), properties.getExpire(), SignatureAlgorithm.HS512);
// 将签发的JWT存储到Redis: {JWT-SESSION-{appID} , jwt}
redisTemplate.opsForValue().set("JWT-SESSION-" + IpUtil.getIpFromRequest((HttpServletRequest) request) + "_" + jwtAccount.getSub(), newJwt, properties.getExpire() << 1, TimeUnit.SECONDS);
writer.print(JSON.toJSONString(AjaxResult.success("刷新令牌", 1006, newJwt)));
} else {
writer.print(JSON.toJSONString(AjaxResult.success("令牌无效!", 1008, null)));
}
writer.flush();
return false;
}
writer.print(JSON.toJSONString(AjaxResult.success(message, 1008, null)));
writer.flush();
return false;
}
}
- 配置config
shiroFilter管理
package com.luoyx.vjsb.authority.shiro.filter;
import com.luoyx.vjsb.common.holder.SpringContextHolder;
import com.luoyx.vjsb.common.properties.VjsbProperties;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.filter.mgt.DefaultFilterChainManager;
import org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver;
import org.apache.shiro.web.servlet.AbstractShiroFilter;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.Filter;
import java.util.*;
/**
* <p>
* Shiro Filter 管理器
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/20 11:00
*/
@Slf4j
@Component
public class ShiroFilterChainManager {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private VjsbProperties vjsbProperties;
/**
* 初始化获取过滤链
*
* @return java.util.Map<java.lang.String, javax.servlet.Filter>
*/
public Map<String, Filter> initGetFilters() {
Map<String, Filter> filters = new LinkedHashMap<>();
Oauth2Filter jwtFilter = new Oauth2Filter();
jwtFilter.setRedisTemplate(stringRedisTemplate);
jwtFilter.setProperties(vjsbProperties);
filters.put("oauth2", jwtFilter);
return filters;
}
/**
* 初始化获取过滤链规则
*
* @return java.util.Map<java.lang.String, java.lang.String>
*/
public Map<String, String> initGetFilterChain() {
Map<String, String> filterChain = new LinkedHashMap<>();
// -------------anon 默认过滤器忽略的URL
List<String> defaultAnon = Arrays.asList("/css/**", "/js/**", "/login");
defaultAnon.forEach(ignored -> filterChain.put(ignored, "anon"));
// -------------auth 默认需要认证过滤器的URL 走auth--PasswordFilter
List<String> defaultAuth = Collections.singletonList("/**");
defaultAuth.forEach(auth -> filterChain.put(auth, "oauth2"));
return filterChain;
}
/**
* 动态重新加载过滤链规则
*/
public void reloadFilterChain() {
ShiroFilterFactoryBean shiroFilterFactoryBean = SpringContextHolder.getBean(ShiroFilterFactoryBean.class);
AbstractShiroFilter abstractShiroFilter = null;
try {
abstractShiroFilter = (AbstractShiroFilter) shiroFilterFactoryBean.getObject();
assert abstractShiroFilter != null;
PathMatchingFilterChainResolver filterChainResolver = (PathMatchingFilterChainResolver) abstractShiroFilter.getFilterChainResolver();
DefaultFilterChainManager filterChainManager = (DefaultFilterChainManager) filterChainResolver.getFilterChainManager();
filterChainManager.getFilterChains().clear();
shiroFilterFactoryBean.getFilterChainDefinitionMap().clear();
shiroFilterFactoryBean.setFilterChainDefinitionMap(this.initGetFilterChain());
shiroFilterFactoryBean.getFilterChainDefinitionMap().forEach(filterChainManager::createChain);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}
shiro配置类
package com.luoyx.vjsb.authority.shiro.config;
import com.luoyx.vjsb.authority.shiro.filter.ShiroFilterChainManager;
import com.luoyx.vjsb.authority.shiro.realm.Oauth2Realm;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.DefaultSessionManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* <p>
* shiro 权限配置
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/19 15:17
*/
@Slf4j
@Configuration
public class ShiroConfiguration {
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, ShiroFilterChainManager filterChainManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
shiroFilterFactoryBean.setFilters(filterChainManager.initGetFilters());
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainManager.initGetFilterChain());
return shiroFilterFactoryBean;
}
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(jwtRealm());
log.info("设置sessionManager禁用掉会话调度器");
securityManager.setSessionManager(sessionManager());
// 无状态subjectFactory设置
DefaultSessionStorageEvaluator evaluator = (DefaultSessionStorageEvaluator) ((DefaultSubjectDAO) securityManager.getSubjectDAO()).getSessionStorageEvaluator();
evaluator.setSessionStorageEnabled(Boolean.FALSE);
StatelessDefaultSubjectFactory subjectFactory = new StatelessDefaultSubjectFactory();
securityManager.setSubjectFactory(subjectFactory);
SecurityUtils.setSecurityManager(securityManager);
return securityManager;
}
@Bean
public Oauth2Realm jwtRealm() {
return new Oauth2Realm();
}
/**
* session管理器
* sessionManager通过sessionValidationSchedulerEnabled禁用掉会话调度器,
* 因为我们禁用掉了会话,所以没必要再定期过期会话了。
*
* @return 1
*/
@Bean
public DefaultSessionManager sessionManager() {
DefaultSessionManager sessionManager = new DefaultSessionManager();
sessionManager.setSessionValidationSchedulerEnabled(Boolean.FALSE);
return sessionManager;
}
}
StatelessDefaultSubjectFactory
package com.luoyx.vjsb.authority.shiro.config;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.SubjectContext;
import org.apache.shiro.web.mgt.DefaultWebSubjectFactory;
/**
* <p>
*
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/20 16:24
*/
public class StatelessDefaultSubjectFactory extends DefaultWebSubjectFactory {
@Override
public Subject createSubject(SubjectContext context) {
//不创建session
context.setSessionCreationEnabled(false);
return super.createSubject(context);
}
}
token工具生成类
package com.luoyx.vjsb.authority.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.luoyx.vjsb.authority.vo.JwtAccount;
import io.jsonwebtoken.*;
import io.jsonwebtoken.impl.DefaultHeader;
import io.jsonwebtoken.impl.DefaultJwsHeader;
import io.jsonwebtoken.impl.TextCodec;
import io.jsonwebtoken.impl.compression.DefaultCompressionCodecResolver;
import io.jsonwebtoken.lang.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import javax.xml.bind.DatatypeConverter;
import java.io.IOException;
import java.util.*;
/**
* <p>
* token生成器
* </p>
*
* @author luoyuanxiang <p>luoyuanxiang.github.io</p>
* @since 2020/3/19 15:37
*/
public class JsonWebTokenUtil {
public static final String SECRET_KEY = "?::4343fdf4fdf6cvf):";
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int COUNT_2 = 2;
private static CompressionCodecResolver codecResolver = new DefaultCompressionCodecResolver();
private JsonWebTokenUtil() {
}
/**
* json web token 签发
*
* @param id 令牌ID
* @param subject 用户ID
* @param issuer 签发人
* @param period 有效时间(秒)
* @param password 用户密码
* @param algorithm 加密算法
* @return java.lang.String
*/
public static String createToken(String id, String subject, String issuer, String password, Long period, SignatureAlgorithm algorithm) {
// 当前时间戳
long currentTimeMillis = System.currentTimeMillis();
// 秘钥
byte[] secreKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);
JwtBuilder jwtBuilder = Jwts.builder();
Optional.ofNullable(id)
.ifPresent(i-> {
jwtBuilder.setId(id);
});
if (!StringUtils.isEmpty(id)) {
jwtBuilder.setId(id);
}
if (!StringUtils.isEmpty(subject)) {
jwtBuilder.setSubject(subject);
}
if (!StringUtils.isEmpty(issuer)) {
jwtBuilder.setIssuer(issuer);
}
if (!StringUtils.isEmpty(password)) {
jwtBuilder.claim("password", password);
}
// 设置签发时间
jwtBuilder.setIssuedAt(new Date(currentTimeMillis));
// 设置到期时间
if (null != period) {
jwtBuilder.setExpiration(new Date(currentTimeMillis + period * 1000));
}
// 压缩,可选GZIP
jwtBuilder.compressWith(CompressionCodecs.DEFLATE);
// 加密设置
jwtBuilder.signWith(algorithm, secreKeyBytes);
return jwtBuilder.compact();
}
/**
* 解析JWT的Payload
*/
public static String parseJwtPayload(String jwt) {
Assert.hasText(jwt, "JWT String argument cannot be null or empty.");
String base64UrlEncodedHeader = null;
String base64UrlEncodedPayload = null;
String base64UrlEncodedDigest = null;
int delimiterCount = 0;
StringBuilder sb = new StringBuilder(128);
for (char c : jwt.toCharArray()) {
if (c == '.') {
CharSequence tokenSeq = io.jsonwebtoken.lang.Strings.clean(sb);
String token = tokenSeq != null ? tokenSeq.toString() : null;
if (delimiterCount == 0) {
base64UrlEncodedHeader = token;
} else if (delimiterCount == 1) {
base64UrlEncodedPayload = token;
}
delimiterCount++;
sb.setLength(0);
} else {
sb.append(c);
}
}
if (delimiterCount != COUNT_2) {
String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
throw new MalformedJwtException(msg);
}
if (sb.length() > 0) {
base64UrlEncodedDigest = sb.toString();
}
if (base64UrlEncodedPayload == null) {
throw new MalformedJwtException("JWT string '" + jwt + "' is missing a body/payload.");
}
// =============== Header =================
Header header = null;
CompressionCodec compressionCodec = null;
if (base64UrlEncodedHeader != null) {
String origValue = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
Map<String, Object> m = readValue(origValue);
if (base64UrlEncodedDigest != null) {
header = new DefaultJwsHeader(m);
} else {
header = new DefaultHeader(m);
}
compressionCodec = codecResolver.resolveCompressionCodec(header);
}
// =============== Body =================
String payload;
if (compressionCodec != null) {
byte[] decompressed = compressionCodec.decompress(TextCodec.BASE64URL.decode(base64UrlEncodedPayload));
payload = new String(decompressed, io.jsonwebtoken.lang.Strings.UTF_8);
} else {
payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedPayload);
}
return payload;
}
/**
* 验签JWT
*
* @param jwt json web token
* @param appKey key
* @return JwtAccount
* @throws ExpiredJwtException 异常
* @throws UnsupportedJwtException 异常
* @throws MalformedJwtException 异常
* @throws SignatureException 异常
* @throws IllegalArgumentException 异常
*/
public static JwtAccount parseJwt(String jwt, String appKey) throws ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException {
Claims claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(appKey))
.parseClaimsJws(jwt)
.getBody();
JwtAccount jwtAccount = new JwtAccount();
// 令牌ID
jwtAccount.setJti(claims.getId())
// 客户标识
.setSub(claims.getSubject())
// 签发者
.setIss(claims.getIssuer())
// 签发时间
.setIat(claims.getIssuedAt().getTime())
.setExp(claims.getExpiration().getTime())
// 密码
.setPassword(claims.get("password", String.class));
return jwtAccount;
}
/**
* description 从json数据中读取格式化map
*
* @param val 1
* @return java.util.Map<java.lang.String, java.lang.Object>
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> readValue(String val) {
try {
return MAPPER.readValue(val, Map.class);
} catch (IOException e) {
throw new MalformedJwtException("Unable to read JSON value: " + val, e);
}
}
/**
* 分割字符串进SET
*/
@SuppressWarnings("unchecked")
public static Set<String> split(String str) {
Set<String> set = new HashSet<>();
if (StringUtils.isEmpty(str)) {
return set;
}
set.addAll(CollectionUtils.arrayToList(str.split(",")));
return set;
}
}
到此项目差不多搞定
项目源码:地址
没有收拾残局的能力,就别放纵善变的情绪