最近看到一个有趣的开源项目pig,主要的技术点在认证授权中心,spring security oauth,zuul网关实现,Elastic-Job定时任务,趁着刚刚入门微服务,赶快写个博客分析一下。此篇文章主要用于个人备忘。如果有不对,请批评。:sob:
由于每个模块篇幅较长,且部分内容和前文有重叠,干货和图片较少,阅读时使用旁边的导航功能体验较佳。:wink:
想要解锁更多新姿势?请访问https://blog.tengshe789.tech/
本篇文章是对基于 spring boot
1.5的 pig 1
版本做的分析,不是收费的 pigx 2
版本。
gitee.com/log4j/pig
配置中心: gitee.com/cqzqxq_lxh/…
pig4cloud.com/zh-cn/index…
pigx.pig4cloud.com/#/wel/index
请确保启动顺序( 要先启动认证中心,再启动网关 )
老规矩,自上到下看代码,先从接口层看起
@RestController @RequestMapping("/authentication") public class AuthenticationController { @Autowired @Qualifier("consumerTokenServices") private ConsumerTokenServices consumerTokenServices; /** * 认证页面 * @return ModelAndView */ @GetMapping("/require") public ModelAndView require() { return new ModelAndView("ftl/login"); } /** * 用户信息校验 * @param authentication 信息 * @return 用户信息 */ @RequestMapping("/user") public Object user(Authentication authentication) { return authentication.getPrincipal(); } /** * 清除Redis中 accesstoken refreshtoken * * @param accesstoken accesstoken * @return true/false */ @PostMapping("/removeToken") @CacheEvict(value = SecurityConstants.TOKEN_USER_DETAIL, key = "#accesstoken") public R<Boolean> removeToken(String accesstoken) { return new R<>( consumerTokenServices.revokeToken(accesstoken)); } } 复制代码
接口层有三个接口路径,第一个应该没用,剩下两个是校验用户信息的 /user
和清除Redis中 accesstoken 与refreshtoken的 /removeToken
下面这段代码时配置各种 spring security
配置,包括登陆界面url是 "/authentication/require"
啦。如果不使用默认的弹出框而使用自己的页面,表单的action是 "/authentication/form"
啦。使用自己定义的过滤规则啦。禁用 csrf
啦(自行搜索csrf,jwt验证不需要防跨域,但是需要使用xss过滤)。使用手机登陆配置啦。
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER - 1) @Configuration @EnableWebSecurity public class PigSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter { @Autowired private FilterIgnorePropertiesConfig filterIgnorePropertiesConfig; @Autowired private MobileSecurityConfigurer mobileSecurityConfigurer; @Override public void configure(HttpSecurity http) throws Exception { ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = http.formLogin().loginPage("/authentication/require") .loginProcessingUrl("/authentication/form") .and() .authorizeRequests(); filterIgnorePropertiesConfig.getUrls().forEach(url -> registry.antMatchers(url).permitAll()); registry.anyRequest().authenticated() .and() .csrf().disable(); http.apply(mobileSecurityConfigurer); } } 复制代码
读配置类和接口层,我们知道了,总的逻辑大概就是用户登陆了以后,使用spring security框架的认证来获取权限。
我们一步一步看,边猜想边来。接口处有 "ftl/login"
,这大概就是使用freemarker模板,login信息携带的 token
会传到用户信息校验url "/user"
上,可作者直接使用 Authentication
返回一个 getPrincipal()
,就没了,根本没看见自定义的代码,这是怎么回事呢?
原来,作者使用 spring security
框架,使用框架来实现校验信息。
打卡 config
包下的 PigAuthorizationConfig
,我们来一探究竟。
注明,阅读此处模块需要OAUTH基础, blog.tengshe789.tech/2018/12/02/…
这里简单提一下, spring security oauth
里有两个概念,授权服务器和资源服务器。
授权服务器是根据授权许可给访问的客户端发放 access token
令牌的,提供认证、授权服务;
资源服务器需要验证这个 access token
,客户端才能访问对应服务。
ClientDetailsServiceConfigurer
( AuthorizationServerConfigurer
的一个回调配置项) 能够使用内存或者JDBC来实现客户端详情服务(ClientDetailsService), Spring Security OAuth2
的配置方法是编写 @Configuration
类继承 AuthorizationServerConfigurerAdapter
,然后重写 void configure(ClientDetailsServiceConfigurer clients)
方法
下面代码主要逻辑是,使用 spring security
框架封装的简单sql连接器,查询客户端的详细信息:point_down:
@Override public void configure(` clients) throws Exception { JdbcClientDetailsService clientDetailsService = new JdbcClientDetailsService(dataSource); clientDetailsService.setSelectClientDetailsSql(SecurityConstants.DEFAULT_SELECT_STATEMENT); clientDetailsService.setFindClientDetailsSql(SecurityConstants.DEFAULT_FIND_STATEMENT); clients.withClientDetails(clientDetailsService); } 复制代码
相关的sql语句如下,由于耦合度较大,我将sql声明语句改了一改,方面阅读:
/** * 默认的查询语句 */ String DEFAULT_FIND_STATEMENT = "select " + "client_id, client_secret, resource_ids, scope, " + "authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, " + "refresh_token_validity, additional_information, autoapprove" + " from sys_oauth_client_details" + " order by client_id"; /** * 按条件client_id 查询 */ String DEFAULT_SELECT_STATEMENT = "select " +"client_id, client_secret, resource_ids, scope, " + "authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, " + "refresh_token_validity, additional_information, autoapprove" + " from sys_oauth_client_details" + " where client_id = ?"; 复制代码
相关数据库信息如下:
endpoints
参数是什么?所有获取令牌的请求都将会在Spring MVC controller endpoints
中进行处理
@Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { //token增强配置 TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain(); tokenEnhancerChain.setTokenEnhancers( Arrays.asList(tokenEnhancer(), jwtAccessTokenConverter())); endpoints .tokenStore(redisTokenStore()) .tokenEnhancer(tokenEnhancerChain) .authenticationManager(authenticationManager) .reuseRefreshTokens(false) .userDetailsService(userDetailsService); } 复制代码
有时候需要额外的信息加到token返回中,这部分也可以自定义,此时我们可以自定义一个 TokenEnhancer
,来自定义生成token携带的信息。 TokenEnhancer
接口提供一个 enhance(OAuth2AccessToken var1, OAuth2Authentication var2)
方法,用于对token信息的添加,信息来源于 OAuth2Authentication
。
作者将生成的 accessToken
中,加上了自己的名字,加上了 userId
@Bean public TokenEnhancer tokenEnhancer() { return (accessToken, authentication) -> { final Map<String, Object> additionalInfo = new HashMap<>(2); additionalInfo.put("license", SecurityConstants.PIG_LICENSE); UserDetailsImpl user = (UserDetailsImpl) authentication.getUserAuthentication().getPrincipal(); if (user != null) { additionalInfo.put("userId", user.getUserId()); } ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; }; } 复制代码
JWT中,需要在token中携带额外的信息,这样可以在服务之间共享部分用户信息,spring security默认在JWT的token中加入了user_name,如果我们需要额外的信息,需要自定义这部分内容。
JwtAccessTokenConverter
是使用 JWT
替换默认的Token的转换器,而token令牌默认是有签名的,且资源服务器需要验证这个签名。此处的加密及验签包括两种方式:
对称加密
非对称加密(公钥密钥)
对称加密需要授权服务器和资源服务器存储同一key值,而非对称加密可使用密钥加密,暴露公钥给资源服务器验签
public class PigJwtAccessTokenConverter extends JwtAccessTokenConverter { @Override public Map<String, ?> convertAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) { Map<String, Object> representation = (Map<String, Object>) super.convertAccessToken(token, authentication); representation.put("license", SecurityConstants.PIG_LICENSE); return representation; } @Override public OAuth2AccessToken extractAccessToken(String value, Map<String, ?> map) { return super.extractAccessToken(value, map); } @Override public OAuth2Authentication extractAuthentication(Map<String, ?> map) { return super.extractAuthentication(map); } } 复制代码
使用鉴权的 endpoint
将加上自己名字的 token
放入 redis
,redis连接器用的 srping data redis
框架
/** * tokenstore 定制化处理 * * @return TokenStore * 1. 如果使用的 redis-cluster 模式请使用 PigRedisTokenStore * PigRedisTokenStore tokenStore = new PigRedisTokenStore(); * tokenStore.setRedisTemplate(redisTemplate); */ @Bean public TokenStore redisTokenStore() { RedisTokenStore tokenStore = new RedisTokenStore(redisConnectionFactory); tokenStore.setPrefix(SecurityConstants.PIG_PREFIX); return tokenStore; } 复制代码
@Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security .allowFormAuthenticationForClients() .tokenKeyAccess("isAuthenticated()") .checkTokenAccess("permitAll()"); } 复制代码
先看接口层,这里和 pig-upms-service
联动,给了三个路径,用户使用手机号码登陆可通过三个路径发送请求
@FeignClient(name = "pig-upms-service", fallback = UserServiceFallbackImpl.class) public interface UserService { /** * 通过用户名查询用户、角色信息 * * @param username 用户名 * @return UserVo */ @GetMapping("/user/findUserByUsername/{username}") UserVO findUserByUsername(@PathVariable("username") String username); /** * 通过手机号查询用户、角色信息 * * @param mobile 手机号 * @return UserVo */ @GetMapping("/user/findUserByMobile/{mobile}") UserVO findUserByMobile(@PathVariable("mobile") String mobile); /** * 根据OpenId查询用户信息 * @param openId openId * @return UserVo */ @GetMapping("/user/findUserByOpenId/{openId}") UserVO findUserByOpenId(@PathVariable("openId") String openId); } 复制代码
重写 SecurityConfigurerAdapter
的方法,通过http请求,找出有关手机号的token,用token找出相关用户的信息,已 Authentication
方式保存。拿到信息后,使用过滤器验证
@Component public class MobileSecurityConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Autowired private AuthenticationSuccessHandler mobileLoginSuccessHandler; @Autowired private UserService userService; @Override public void configure(HttpSecurity http) throws Exception { MobileAuthenticationFilter mobileAuthenticationFilter = new MobileAuthenticationFilter(); mobileAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); mobileAuthenticationFilter.setAuthenticationSuccessHandler(mobileLoginSuccessHandler); MobileAuthenticationProvider mobileAuthenticationProvider = new MobileAuthenticationProvider(); mobileAuthenticationProvider.setUserService(userService); http.authenticationProvider(mobileAuthenticationProvider) .addFilterAfter(mobileAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } } 复制代码
在 spring security
中, AuthenticationManage
管理一系列的 AuthenticationProvider
,
而每一个 Provider
都会通 UserDetailsService
和 UserDetail
来返回一个
以 MobileAuthenticationToken
实现的带用户以及权限的 Authentication
此处逻辑是,通过 UserService
查找已有用户的手机号码,生成对应的 UserDetails
,使用UserDetails生成手机验证 Authentication
@Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { MobileAuthenticationToken mobileAuthenticationToken = (MobileAuthenticationToken) authentication; UserVO userVo = userService.findUserByMobile((String) mobileAuthenticationToken.getPrincipal()); if (userVo == null) { throw new UsernameNotFoundException("手机号不存在:" + mobileAuthenticationToken.getPrincipal()); } UserDetailsImpl userDetails = buildUserDeatils(userVo); MobileAuthenticationToken authenticationToken = new MobileAuthenticationToken(userDetails, userDetails.getAuthorities()); authenticationToken.setDetails(mobileAuthenticationToken.getDetails()); return authenticationToken; } private UserDetailsImpl buildUserDeatils(UserVO userVo) { return new UserDetailsImpl(userVo); } @Override public boolean supports(Class<?> authentication) { return MobileAuthenticationToken.class.isAssignableFrom(authentication); } 复制代码
MobileAuthenticationToken
继承 AbstractAuthenticationToken
实现 Authentication
所以当在页面中输入手机之后首先会进入到 MobileAuthenticationToken
验证(Authentication),
然后生成的 Authentication
会被交由我上面说的 AuthenticationManager
来进行管理
public class MobileAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; private final Object principal; public MobileAuthenticationToken(String mobile) { super(null); this.principal = mobile; setAuthenticated(false); } public MobileAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; super.setAuthenticated(true); } @Override public Object getPrincipal() { return this.principal; } @Override public Object getCredentials() { return null; } @Override public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException( "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); } super.setAuthenticated(false); } @Override public void eraseCredentials() { super.eraseCredentials(); } } 复制代码
判断http请求是否是post,不是则返回错误。
根据request请求拿到moblie信息,使用moblie信息返回手机号码登陆成功的oauth token。
@Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (postOnly && !request.getMethod().equals(HttpMethod.POST.name())) { throw new AuthenticationServiceException( "Authentication method not supported: " + request.getMethod()); } String mobile = obtainMobile(request); if (mobile == null) { mobile = ""; } mobile = mobile.trim(); MobileAuthenticationToken mobileAuthenticationToken = new MobileAuthenticationToken(mobile); setDetails(request, mobileAuthenticationToken); return this.getAuthenticationManager().authenticate(mobileAuthenticationToken); } 复制代码
这个处理器可以返回手机号登录成功的 oauth token
,但是要将 oauth token
传输出去必须配合上面的手机号登录验证filter
逻辑都在注释中
@Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith(BASIC_)) { throw new UnapprovedClientAuthenticationException("请求头中client信息为空"); } try { String[] tokens = AuthUtils.extractAndDecodeHeader(header); assert tokens.length == 2; String clientId = tokens[0]; ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId); //校验secret if (!clientDetails.getClientSecret().equals(tokens[1])) { throw new InvalidClientException("Given client ID does not match authenticated client"); } TokenRequest tokenRequest = new TokenRequest(MapUtil.newHashMap(), clientId, clientDetails.getScope(), "mobile"); //校验scope new DefaultOAuth2RequestValidator().validateScope(tokenRequest, clientDetails); OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails); OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, authentication); OAuth2AccessToken oAuth2AccessToken = authorizationServerTokenServices.createAccessToken(oAuth2Authentication); log.info("获取token 成功:{}", oAuth2AccessToken.getValue()); response.setCharacterEncoding(CommonConstant.UTF8); response.setContentType(CommonConstant.CONTENT_TYPE); PrintWriter printWriter = response.getWriter(); printWriter.append(objectMapper.writeValueAsString(oAuth2AccessToken)); } catch (IOException e) { throw new BadCredentialsException( "Failed to decode basic authentication token"); } } /** * 从header 请求中的clientId/clientsecect * * @param header header中的参数 * @throws CheckedException if the Basic header is not present or is not valid * Base64 */ public static String[] extractAndDecodeHeader(String header) throws IOException { byte[] base64Token = header.substring(6).getBytes("UTF-8"); byte[] decoded; try { decoded = Base64.decode(base64Token); } catch (IllegalArgumentException e) { throw new CheckedException( "Failed to decode basic authentication token"); } String token = new String(decoded, CommonConstant.UTF8); int delim = token.indexOf(":"); if (delim == -1) { throw new CheckedException("Invalid basic authentication token"); } return new String[]{token.substring(0, delim), token.substring(delim + 1)}; } 复制代码
挺好的模板,收藏一下
public class PigRedisTokenStore implements TokenStore { private static final String ACCESS = "access:"; private static final String AUTH_TO_ACCESS = "auth_to_access:"; private static final String AUTH = "auth:"; private static final String REFRESH_AUTH = "refresh_auth:"; private static final String ACCESS_TO_REFRESH = "access_to_refresh:"; private static final String REFRESH = "refresh:"; private static final String REFRESH_TO_ACCESS = "refresh_to_access:"; private static final String CLIENT_ID_TO_ACCESS = "client_id_to_access:"; private static final String UNAME_TO_ACCESS = "uname_to_access:"; private RedisTemplate<String, Object> redisTemplate; public RedisTemplate<String, Object> getRedisTemplate() { return redisTemplate; } public void setRedisTemplate(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } private AuthenticationKeyGenerator authenticationKeyGenerator = new DefaultAuthenticationKeyGenerator(); public void setAuthenticationKeyGenerator(AuthenticationKeyGenerator authenticationKeyGenerator) { this.authenticationKeyGenerator = authenticationKeyGenerator; } @Override public OAuth2AccessToken getAccessToken(OAuth2Authentication authentication) { String key = authenticationKeyGenerator.extractKey(authentication); OAuth2AccessToken accessToken = (OAuth2AccessToken) redisTemplate.opsForValue().get(AUTH_TO_ACCESS + key); if (accessToken != null && !key.equals(authenticationKeyGenerator.extractKey(readAuthentication(accessToken.getValue())))) { storeAccessToken(accessToken, authentication); } return accessToken; } @Override public OAuth2Authentication readAuthentication(OAuth2AccessToken token) { return readAuthentication(token.getValue()); } @Override public OAuth2Authentication readAuthentication(String token) { return (OAuth2Authentication) this.redisTemplate.opsForValue().get(AUTH + token); } @Override public OAuth2Authentication readAuthenticationForRefreshToken(OAuth2RefreshToken token) { return readAuthenticationForRefreshToken(token.getValue()); } public OAuth2Authentication readAuthenticationForRefreshToken(String token) { return (OAuth2Authentication) this.redisTemplate.opsForValue().get(REFRESH_AUTH + token); } @Override public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) { this.redisTemplate.opsForValue().set(ACCESS + token.getValue(), token); this.redisTemplate.opsForValue().set(AUTH + token.getValue(), authentication); this.redisTemplate.opsForValue().set(AUTH_TO_ACCESS + authenticationKeyGenerator.extractKey(authentication), token); if (!authentication.isClientOnly()) { redisTemplate.opsForList().rightPush(UNAME_TO_ACCESS + getApprovalKey(authentication), token); } redisTemplate.opsForList().rightPush(CLIENT_ID_TO_ACCESS + authentication.getOAuth2Request().getClientId(), token); if (token.getExpiration() != null) { int seconds = token.getExpiresIn(); redisTemplate.expire(ACCESS + token.getValue(), seconds, TimeUnit.SECONDS); redisTemplate.expire(AUTH + token.getValue(), seconds, TimeUnit.SECONDS); redisTemplate.expire(AUTH_TO_ACCESS + authenticationKeyGenerator.extractKey(authentication), seconds, TimeUnit.SECONDS); redisTemplate.expire(CLIENT_ID_TO_ACCESS + authentication.getOAuth2Request().getClientId(), seconds, TimeUnit.SECONDS); redisTemplate.expire(UNAME_TO_ACCESS + getApprovalKey(authentication), seconds, TimeUnit.SECONDS); } if (token.getRefreshToken() != null && token.getRefreshToken().getValue() != null) { this.redisTemplate.opsForValue().set(REFRESH_TO_ACCESS + token.getRefreshToken().getValue(), token.getValue()); this.redisTemplate.opsForValue().set(ACCESS_TO_REFRESH + token.getValue(), token.getRefreshToken().getValue()); } } private String getApprovalKey(OAuth2Authentication authentication) { String userName = authentication.getUserAuthentication() == null ? "" : authentication.getUserAuthentication() .getName(); return getApprovalKey(authentication.getOAuth2Request().getClientId(), userName); } private String getApprovalKey(String clientId, String userName) { return clientId + (userName == null ? "" : ":" + userName); } @Override public void removeAccessToken(OAuth2AccessToken accessToken) { removeAccessToken(accessToken.getValue()); } @Override public OAuth2AccessToken readAccessToken(String tokenValue) { return (OAuth2AccessToken) this.redisTemplate.opsForValue().get(ACCESS + tokenValue); } public void removeAccessToken(String tokenValue) { OAuth2AccessToken removed = (OAuth2AccessToken) redisTemplate.opsForValue().get(ACCESS + tokenValue); // caller to do that OAuth2Authentication authentication = (OAuth2Authentication) this.redisTemplate.opsForValue().get(AUTH + tokenValue); this.redisTemplate.delete(AUTH + tokenValue); redisTemplate.delete(ACCESS + tokenValue); this.redisTemplate.delete(ACCESS_TO_REFRESH + tokenValue); if (authentication != null) { this.redisTemplate.delete(AUTH_TO_ACCESS + authenticationKeyGenerator.extractKey(authentication)); String clientId = authentication.getOAuth2Request().getClientId(); redisTemplate.opsForList().leftPop(UNAME_TO_ACCESS + getApprovalKey(clientId, authentication.getName())); redisTemplate.opsForList().leftPop(CLIENT_ID_TO_ACCESS + clientId); this.redisTemplate.delete(AUTH_TO_ACCESS + authenticationKeyGenerator.extractKey(authentication)); } } @Override public void storeRefreshToken(OAuth2RefreshToken refreshToken, OAuth2Authentication authentication) { this.redisTemplate.opsForValue().set(REFRESH + refreshToken.getValue(), refreshToken); this.redisTemplate.opsForValue().set(REFRESH_AUTH + refreshToken.getValue(), authentication); } @Override public OAuth2RefreshToken readRefreshToken(String tokenValue) { return (OAuth2RefreshToken) this.redisTemplate.opsForValue().get(REFRESH + tokenValue); } @Override public void removeRefreshToken(OAuth2RefreshToken refreshToken) { removeRefreshToken(refreshToken.getValue()); } public void removeRefreshToken(String tokenValue) { this.redisTemplate.delete(REFRESH + tokenValue); this.redisTemplate.delete(REFRESH_AUTH + tokenValue); this.redisTemplate.delete(REFRESH_TO_ACCESS + tokenValue); } @Override public void removeAccessTokenUsingRefreshToken(OAuth2RefreshToken refreshToken) { removeAccessTokenUsingRefreshToken(refreshToken.getValue()); } private void removeAccessTokenUsingRefreshToken(String refreshToken) { String token = (String) this.redisTemplate.opsForValue().get(REFRESH_TO_ACCESS + refreshToken); if (token != null) { redisTemplate.delete(ACCESS + token); } } @Override public Collection<OAuth2AccessToken> findTokensByClientIdAndUserName(String clientId, String userName) { List<Object> result = redisTemplate.opsForList().range(UNAME_TO_ACCESS + getApprovalKey(clientId, userName), 0, -1); if (result == null || result.size() == 0) { return Collections.emptySet(); } List<OAuth2AccessToken> accessTokens = new ArrayList<>(result.size()); for (Iterator<Object> it = result.iterator(); it.hasNext(); ) { OAuth2AccessToken accessToken = (OAuth2AccessToken) it.next(); accessTokens.add(accessToken); } return Collections.unmodifiableCollection(accessTokens); } @Override public Collection<OAuth2AccessToken> findTokensByClientId(String clientId) { List<Object> result = redisTemplate.opsForList().range((CLIENT_ID_TO_ACCESS + clientId), 0, -1); if (result == null || result.size() == 0) { return Collections.emptySet(); } List<OAuth2AccessToken> accessTokens = new ArrayList<>(result.size()); for (Iterator<Object> it = result.iterator(); it.hasNext(); ) { OAuth2AccessToken accessToken = (OAuth2AccessToken) it.next(); accessTokens.add(accessToken); } return Collections.unmodifiableCollection(accessTokens); } } 复制代码
网关主体在包 pig/pig-gateway/src/main/java/com/github/pig/gateway
下
作者使用了Zuul做为网关,它Netflix开源的微服务网关,可以和Eureka,Ribbon,Hystrix等组件配合使用。
Zuul组件的核心是一系列的过滤器,这些过滤器可以完成以下功能:
身份认证和安全: 识别每一个资源的验证要求,并拒绝那些不符的请求
审查与监控:
动态路由:动态将请求路由到不同后端集群
压力测试:逐渐增加指向集群的流量,以了解性能
负载分配:为每一种负载类型分配对应容量,并弃用超出限定值的请求
静态响应处理:边缘位置进行响应,避免转发到内部集群
多区域弹性:跨域AWS Region进行请求路由,旨在实现ELB(ElasticLoad Balancing)使用多样化
Zuul组件的核心是一系列的过滤器,我们先从过滤器下手。
@Component public class ErrorHandlerFilter extends ZuulFilter { @Autowired private LogSendService logSendService; @Override public String filterType() { return ERROR_TYPE; } @Override public int filterOrder() { return SEND_RESPONSE_FILTER_ORDER + 1; } @Override public boolean shouldFilter() { RequestContext requestContext = RequestContext.getCurrentContext(); return requestContext.getThrowable() != null; } @Override public Object run() { RequestContext requestContext = RequestContext.getCurrentContext(); logSendService.send(requestContext); return null; } } 复制代码
作者以原生zuul过滤器为基础加了日志配置,优先级为+1,数字越大优先级越低。
public class XssSecurityFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper(request); filterChain.doFilter(xssRequest, response); } 复制代码
重写springMVC里面的的确保在一次请求只通过一次filter的类 OncePerRequestFilter
,添加一条https://gitee.com/renrenio/renren-fast的工具类 XssHttpServletRequestWrapper
为过滤链条。
@Override public ServletInputStream getInputStream() throws IOException { ····略 //xss过滤 json = xssEncode(json); final ByteArrayInputStream bis = new ByteArrayInputStream(json.getBytes("utf-8")); return new ServletInputStream() { ···略 } }; } 复制代码
此过滤器优先级为+2.每当一个请求不是请求 /oauth/token
或者 /mobile/token
这个地址时,都会解析使用aes解码器 password
。
@Override public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); Map<String, List<String>> params = ctx.getRequestQueryParams(); if (params == null) { return null; } List<String> passList = params.get(PASSWORD); if (CollUtil.isEmpty(passList)) { return null; } String password = passList.get(0); if (StrUtil.isNotBlank(password)) { try { password = decryptAES(password, key); } catch (Exception e) { log.error("密码解密失败:{}", password); } params.put(PASSWORD, CollUtil.newArrayList(password.trim())); } ctx.setRequestQueryParams(params); return null; } 复制代码
逻辑作者都写在注释中了,此处使用了redis做为服务端验证码的缓存
** * 是否校验验证码 * 1. 判断验证码开关是否开启 * 2. 判断请求是否登录请求 * 2.1 判断是不是刷新请求(不用单独在建立刷新客户端) * 3. 判断终端是否支持 * * @return true/false */ @Override public boolean shouldFilter() { HttpServletRequest request = RequestContext.getCurrentContext().getRequest(); if (!StrUtil.containsAnyIgnoreCase(request.getRequestURI(), SecurityConstants.OAUTH_TOKEN_URL, SecurityConstants.MOBILE_TOKEN_URL)) { return false; } if (SecurityConstants.REFRESH_TOKEN.equals(request.getParameter(GRANT_TYPE))) { return false; } try { String[] clientInfos = AuthUtils.extractAndDecodeHeader(request); if (CollUtil.containsAny(filterIgnorePropertiesConfig.getClients(), Arrays.asList(clientInfos))) { return false; } } catch (IOException e) { log.error("解析终端信息失败", e); } return true; } @Override public Object run() { try { checkCode(RequestContext.getCurrentContext().getRequest()); } catch (ValidateCodeException e) { RequestContext ctx = RequestContext.getCurrentContext(); R<String> result = new R<>(e); result.setCode(478); ctx.setResponseStatusCode(478); ctx.setSendZuulResponse(false); ctx.getResponse().setContentType("application/json;charset=UTF-8"); ctx.setResponseBody(JSONObject.toJSONString(result)); } return null; } /** * 检查code * * @param httpServletRequest request * @throws ValidateCodeException 验证码校验异常 */ private void checkCode(HttpServletRequest httpServletRequest) throws ValidateCodeException { String code = httpServletRequest.getParameter("code"); if (StrUtil.isBlank(code)) { throw new ValidateCodeException("请输入验证码"); } String randomStr = httpServletRequest.getParameter("randomStr"); if (StrUtil.isBlank(randomStr)) { randomStr = httpServletRequest.getParameter("mobile"); } String key = SecurityConstants.DEFAULT_CODE_KEY + randomStr; if (!redisTemplate.hasKey(key)) { throw new ValidateCodeException(EXPIRED_CAPTCHA_ERROR); } Object codeObj = redisTemplate.opsForValue().get(key); if (codeObj == null) { throw new ValidateCodeException(EXPIRED_CAPTCHA_ERROR); } String saveCode = codeObj.toString(); if (StrUtil.isBlank(saveCode)) { redisTemplate.delete(key); throw new ValidateCodeException(EXPIRED_CAPTCHA_ERROR); } if (!StrUtil.equals(saveCode, code)) { redisTemplate.delete(key); throw new ValidateCodeException("验证码错误,请重新输入"); } redisTemplate.delete(key); } 复制代码
灰度发布,已经不是一个很新的概念了.一个产品,如果需要快速迭代开发上线,又要保证质量,保证刚上线的系统,一旦出现问题那么可以很快的控制影响面,就需要设计一套灰度发布系统.
灰度发布系统的作用在于,可以根据自己的配置,来将用户的流量导到新上线的系统上,来快速验证新的功能修改,而一旦出问题,也可以马上的恢复,简单的说,就是一套A/BTest系统.
下面是灰度路由初始化类:
@Configuration @ConditionalOnClass(DiscoveryEnabledNIWSServerList.class) @AutoConfigureBefore(RibbonClientConfiguration.class) @ConditionalOnProperty(value = "zuul.ribbon.metadata.enabled") public class RibbonMetaFilterAutoConfiguration { @Bean @ConditionalOnMissingBean @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public ZoneAvoidanceRule metadataAwareRule() { return new MetadataCanaryRuleHandler(); } } 复制代码
首先重写 filterOrder()
方法,使这个过滤器在 在RateLimitPreFilter
之前运行,不会出现空指针问题。此处优先级 FORM_BODY_WRAPPER_FILTER_ORDER-1
.
@Component public class AccessFilter extends ZuulFilter { @Value("${zuul.ribbon.metadata.enabled:false}") private boolean canary; @Override public String filterType() { return FilterConstants.PRE_TYPE; } @Override public int filterOrder() { return FORM_BODY_WRAPPER_FILTER_ORDER - 1; } @Override public boolean shouldFilter() { return true; } @Override public Object run() { RequestContext requestContext = RequestContext.getCurrentContext(); String version = requestContext.getRequest().getHeader(SecurityConstants.VERSION); if (canary && StrUtil.isNotBlank(version)) { RibbonVersionHolder.setContext(version); } requestContext.set("startTime", System.currentTimeMillis()); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null) { requestContext.addZuulRequestHeader(SecurityConstants.USER_HEADER, authentication.getName()); requestContext.addZuulRequestHeader(SecurityConstants.ROLE_HEADER, CollectionUtil.join(authentication.getAuthorities(), ",")); } return null; } } 复制代码
核心方法在run()上,首先受到request请求,拿到他的版本约束信息,然后根据选择添加token
自定义 ribbon
路由规则匹配多版本请求,实现 灰度发布
。复合判断server所在区域的性能和server的可用性选择server,即,使用ZoneAvoidancePredicate和AvailabilityPredicate来判断是否选择某个server,前一个判断判定一个zone的运行性能是否可用,剔除不可用的zone(的所有server),AvailabilityPredicate用于过滤掉连接数过多的Server。
此处逻辑是
@Override public AbstractServerPredicate getPredicate() { return new AbstractServerPredicate() { @Override public boolean apply(PredicateKey predicateKey) { String targetVersion = RibbonVersionHolder.getContext(); RibbonVersionHolder.clearContext(); if (StrUtil.isBlank(targetVersion)) { log.debug("客户端未配置目标版本直接路由"); return true; } DiscoveryEnabledServer server = (DiscoveryEnabledServer) predicateKey.getServer(); final Map<String, String> metadata = server.getInstanceInfo().getMetadata(); if (StrUtil.isBlank(metadata.get(SecurityConstants.VERSION))) { log.debug("当前微服务{} 未配置版本直接路由"); return true; } if (metadata.get(SecurityConstants.VERSION).equals(targetVersion)) { return true; } else { log.debug("当前微服务{} 版本为{},目标版本{} 匹配失败", server.getInstanceInfo().getAppName() , metadata.get(SecurityConstants.VERSION), targetVersion); return false; } } }; } 复制代码
public class DynamicRouteLocator extends DiscoveryClientRouteLocator { private ZuulProperties properties; private RedisTemplate redisTemplate; public DynamicRouteLocator(String servletPath, DiscoveryClient discovery, ZuulProperties properties, ServiceInstance localServiceInstance, RedisTemplate redisTemplate) { super(servletPath, discovery, properties, localServiceInstance); this.properties = properties; this.redisTemplate = redisTemplate; } /** * 重写路由配置 * <p> * 1. properties 配置。 * 2. eureka 默认配置。 * 3. DB数据库配置。 * * @return 路由表 */ @Override protected LinkedHashMap<String, ZuulProperties.ZuulRoute> locateRoutes() { LinkedHashMap<String, ZuulProperties.ZuulRoute> routesMap = new LinkedHashMap<>(); //读取properties配置、eureka默认配置 routesMap.putAll(super.locateRoutes()); log.debug("初始默认的路由配置完成"); routesMap.putAll(locateRoutesFromDb()); LinkedHashMap<String, ZuulProperties.ZuulRoute> values = new LinkedHashMap<>(); for (Map.Entry<String, ZuulProperties.ZuulRoute> entry : routesMap.entrySet()) { String path = entry.getKey(); if (!path.startsWith("/")) { path = "/" + path; } if (StrUtil.isNotBlank(this.properties.getPrefix())) { path = this.properties.getPrefix() + path; if (!path.startsWith("/")) { path = "/" + path; } } values.put(path, entry.getValue()); } return values; } /** * Redis中保存的,没有从upms拉去,避免启动链路依赖问题(取舍),网关依赖业务模块的问题 * * @return */ private Map<String, ZuulProperties.ZuulRoute> locateRoutesFromDb() { Map<String, ZuulProperties.ZuulRoute> routes = new LinkedHashMap<>(); Object obj = redisTemplate.opsForValue().get(CommonConstant.ROUTE_KEY); if (obj == null) { return routes; } List<SysZuulRoute> results = (List<SysZuulRoute>) obj; for (SysZuulRoute result : results) { if (StrUtil.isBlank(result.getPath()) && StrUtil.isBlank(result.getUrl())) { continue; } ZuulProperties.ZuulRoute zuulRoute = new ZuulProperties.ZuulRoute(); try { zuulRoute.setId(result.getServiceId()); zuulRoute.setPath(result.getPath()); zuulRoute.setServiceId(result.getServiceId()); zuulRoute.setRetryable(StrUtil.equals(result.getRetryable(), "0") ? Boolean.FALSE : Boolean.TRUE); zuulRoute.setStripPrefix(StrUtil.equals(result.getStripPrefix(), "0") ? Boolean.FALSE : Boolean.TRUE); zuulRoute.setUrl(result.getUrl()); List<String> sensitiveHeadersList = StrUtil.splitTrim(result.getSensitiveheadersList(), ","); if (sensitiveHeadersList != null) { Set<String> sensitiveHeaderSet = CollUtil.newHashSet(); sensitiveHeadersList.forEach(sensitiveHeader -> sensitiveHeaderSet.add(sensitiveHeader)); zuulRoute.setSensitiveHeaders(sensitiveHeaderSet); zuulRoute.setCustomSensitiveHeaders(true); } } catch (Exception e) { log.error("从数据库加载路由配置异常", e); } log.debug("添加数据库自定义的路由配置,path:{},serviceId:{}", zuulRoute.getPath(), zuulRoute.getServiceId()); routes.put(zuulRoute.getPath(), zuulRoute); } return routes; } } 复制代码
代码注释已经将逻辑写的很清楚了
@Slf4j @Component public class LogSendServiceImpl implements LogSendService { private static final String SERVICE_ID = "serviceId"; @Autowired private AmqpTemplate rabbitTemplate; /** * 1. 获取 requestContext 中的请求信息 * 2. 如果返回状态不是OK,则获取返回信息中的错误信息 * 3. 发送到MQ * * @param requestContext 上下文对象 */ @Override public void send(RequestContext requestContext) { HttpServletRequest request = requestContext.getRequest(); String requestUri = request.getRequestURI(); String method = request.getMethod(); SysLog sysLog = new SysLog(); sysLog.setType(CommonConstant.STATUS_NORMAL); sysLog.setRemoteAddr(HttpUtil.getClientIP(request)); sysLog.setRequestUri(URLUtil.getPath(requestUri)); sysLog.setMethod(method); sysLog.setUserAgent(request.getHeader("user-agent")); sysLog.setParams(HttpUtil.toParams(request.getParameterMap())); Long startTime = (Long) requestContext.get("startTime"); sysLog.setTime(System.currentTimeMillis() - startTime); if (requestContext.get(SERVICE_ID) != null) { sysLog.setServiceId(requestContext.get(SERVICE_ID).toString()); } //正常发送服务异常解析 if (requestContext.getResponseStatusCode() == HttpStatus.SC_INTERNAL_SERVER_ERROR && requestContext.getResponseDataStream() != null) { InputStream inputStream = requestContext.getResponseDataStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); InputStream stream1 = null; InputStream stream2; byte[] buffer = IoUtil.readBytes(inputStream); try { baos.write(buffer); baos.flush(); stream1 = new ByteArrayInputStream(baos.toByteArray()); stream2 = new ByteArrayInputStream(baos.toByteArray()); String resp = IoUtil.read(stream1, CommonConstant.UTF8); sysLog.setType(CommonConstant.STATUS_LOCK); sysLog.setException(resp); requestContext.setResponseDataStream(stream2); } catch (IOException e) { log.error("响应流解析异常:", e); throw new RuntimeException(e); } finally { IoUtil.close(stream1); IoUtil.close(baos); IoUtil.close(inputStream); } } //网关内部异常 Throwable throwable = requestContext.getThrowable(); if (throwable != null) { log.error("网关异常", throwable); sysLog.setException(throwable.getMessage()); } //保存发往MQ(只保存授权) Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && StrUtil.isNotBlank(authentication.getName())) { LogVO logVo = new LogVO(); sysLog.setCreateBy(authentication.getName()); logVo.setSysLog(sysLog); logVo.setUsername(authentication.getName()); rabbitTemplate.convertAndSend(MqQueueConstant.LOG_QUEUE, logVo); } } } 复制代码
重写zuul中默认的限流处理器 DefaultRateLimiterErrorHandler
,使之记录日志内容
@Bean public RateLimiterErrorHandler rateLimitErrorHandler() { return new DefaultRateLimiterErrorHandler() { @Override public void handleSaveError(String key, Exception e) { log.error("保存key:[{}]异常", key, e); } @Override public void handleFetchError(String key, Exception e) { log.error("路由失败:[{}]异常", key); } @Override public void handleError(String msg, Exception e) { log.error("限流异常:[{}]", msg, e); } }; } 复制代码
重写 Srping security oAuth
提供单点登录验证拒绝 OAuth2AccessDeniedHandler
接口,使用R包装失败信息到 PigDeniedException
@Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException authException) throws IOException, ServletException { log.info("授权失败,禁止访问 {}", request.getRequestURI()); response.setCharacterEncoding(CommonConstant.UTF8); response.setContentType(CommonConstant.CONTENT_TYPE); R<String> result = new R<>(new PigDeniedException("授权失败,禁止访问")); response.setStatus(HttpStatus.SC_FORBIDDEN); PrintWriter printWriter = response.getWriter(); printWriter.append(objectMapper.writeValueAsString(result)); } 复制代码
@FeignClient(name = "pig-upms-service", fallback = MenuServiceFallbackImpl.class) public interface MenuService { /** * 通过角色名查询菜单 * * @param role 角色名称 * @return 菜单列表 */ @GetMapping(value = "/menu/findMenuByRole/{role}") Set<MenuVO> findMenuByRole(@PathVariable("role") String role); } 复制代码
使用feign连接pig系统的菜单微服务
@Service("permissionService") public class PermissionServiceImpl implements PermissionService { @Autowired private MenuService menuService; private AntPathMatcher antPathMatcher = new AntPathMatcher(); @Override public boolean hasPermission(HttpServletRequest request, Authentication authentication) { //ele-admin options 跨域配置,现在处理是通过前端配置代理,不使用这种方式,存在风险 // if (HttpMethod.OPTIONS.name().equalsIgnoreCase(request.getMethod())) { // return true; // } Object principal = authentication.getPrincipal(); List<SimpleGrantedAuthority> authorityList = (List<SimpleGrantedAuthority>) authentication.getAuthorities(); AtomicBoolean hasPermission = new AtomicBoolean(false); if (principal != null) { if (CollUtil.isEmpty(authorityList)) { log.warn("角色列表为空:{}", authentication.getPrincipal()); return false; } Set<MenuVO> urls = new HashSet<>(); authorityList.stream().filter(authority -> !StrUtil.equals(authority.getAuthority(), "ROLE_USER")) .forEach(authority -> { Set<MenuVO> menuVOSet = menuService.findMenuByRole(authority.getAuthority()); CollUtil.addAll(urls, menuVOSet); }); urls.stream().filter(menu -> StrUtil.isNotEmpty(menu.getUrl()) && antPathMatcher.match(menu.getUrl(), request.getRequestURI()) && request.getMethod().equalsIgnoreCase(menu.getMethod())) .findFirst().ifPresent(menuVO -> hasPermission.set(true)); } return hasPermission.get(); } } 复制代码
pig这个系统是个很好的框架,本次体验的是pig的zuul网关模块,此模块与feign,ribbon,spring security,Eurasia进行整合,完成或部分完成了 动态路由 , 灰度发布 , 菜单权限管理 , 服务限流 , 网关日志处理 ,非常值得学习!
百度了一下, UPMS 是User Permissions Management System,通用用户权限管理系统
/** * 编号 */ @TableId(value="id", type= IdType.AUTO) private Integer id; /** * 数据值 */ private String value; /** * 标签名 */ private String label; /** * 类型 */ private String type; /** * 描述 */ private String description; /** * 排序(升序) */ private BigDecimal sort; /** * 创建时间 */ @TableField("create_time") private Date createTime; /** * 更新时间 */ @TableField("update_time") private Date updateTime; /** * 备注信息 */ private String remarks; /** * 删除标记 */ @TableField("del_flag") private String delFlag; 复制代码
@Data public class SysLog implements Serializable { private static final long serialVersionUID = 1L; /** * 编号 */ @TableId(type = IdType.ID_WORKER) @JsonSerialize(using = ToStringSerializer.class) private Long id; /** * 日志类型 */ private String type; /** * 日志标题 */ private String title; /** * 创建者 */ private String createBy; /** * 创建时间 */ private Date createTime; /** * 更新时间 */ private Date updateTime; /** * 操作IP地址 */ private String remoteAddr; /** * 用户代理 */ private String userAgent; /** * 请求URI */ private String requestUri; /** * 操作方式 */ private String method; /** * 操作提交的数据 */ private String params; /** * 执行时间 */ private Long time; /** * 删除标记 */ private String delFlag; /** * 异常信息 */ private String exception; /** * 服务ID */ private String serviceId; }} 复制代码
略
略
/** * 主键ID */ @TableId(value = "user_id", type = IdType.AUTO) private Integer userId; /** * 用户名 */ private String username; private String password; /** * 随机盐 */ @JsonIgnore private String salt; /** * 创建时间 */ @TableField("create_time") private Date createTime; /** * 修改时间 */ @TableField("update_time") private Date updateTime; /** * 0-正常,1-删除 */ @TableField("del_flag") private String delFlag; /** * 简介 */ private String phone; /** * 头像 */ private String avatar; /** * 部门ID */ @TableField("dept_id") private Integer deptId; 复制代码
全是基于mybatis plus的CRUD,有点多。大部分干这行的都懂,我就不详细展开了。
ValidateCodeController
可以找到创建验证码相关代码
/** * 创建验证码 * * @param request request * @throws Exception */ @GetMapping(SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX + "/{randomStr}") public void createCode(@PathVariable String randomStr, HttpServletRequest request, HttpServletResponse response) throws Exception { Assert.isBlank(randomStr, "机器码不能为空"); response.setHeader("Cache-Control", "no-store, no-cache"); response.setContentType("image/jpeg"); //生成文字验证码 String text = producer.createText(); //生成图片验证码 BufferedImage image = producer.createImage(text); userService.saveImageCode(randomStr, text); ServletOutputStream out = response.getOutputStream(); ImageIO.write(image, "JPEG", out); IOUtils.closeQuietly(out); } 复制代码
其中的 producer
是使用 Kaptcha
,下面是配置类
@Configuration public class KaptchaConfig { private static final String KAPTCHA_BORDER = "kaptcha.border"; private static final String KAPTCHA_TEXTPRODUCER_FONT_COLOR = "kaptcha.textproducer.font.color"; private static final String KAPTCHA_TEXTPRODUCER_CHAR_SPACE = "kaptcha.textproducer.char.space"; private static final String KAPTCHA_IMAGE_WIDTH = "kaptcha.image.width"; private static final String KAPTCHA_IMAGE_HEIGHT = "kaptcha.image.height"; private static final String KAPTCHA_TEXTPRODUCER_CHAR_LENGTH = "kaptcha.textproducer.char.length"; private static final Object KAPTCHA_IMAGE_FONT_SIZE = "kaptcha.textproducer.font.size"; @Bean public DefaultKaptcha producer() { Properties properties = new Properties(); properties.put(KAPTCHA_BORDER, SecurityConstants.DEFAULT_IMAGE_BORDER); properties.put(KAPTCHA_TEXTPRODUCER_FONT_COLOR, SecurityConstants.DEFAULT_COLOR_FONT); properties.put(KAPTCHA_TEXTPRODUCER_CHAR_SPACE, SecurityConstants.DEFAULT_CHAR_SPACE); properties.put(KAPTCHA_IMAGE_WIDTH, SecurityConstants.DEFAULT_IMAGE_WIDTH); properties.put(KAPTCHA_IMAGE_HEIGHT, SecurityConstants.DEFAULT_IMAGE_HEIGHT); properties.put(KAPTCHA_IMAGE_FONT_SIZE, SecurityConstants.DEFAULT_IMAGE_FONT_SIZE); properties.put(KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, SecurityConstants.DEFAULT_IMAGE_LENGTH); Config config = new Config(properties); DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); defaultKaptcha.setConfig(config); return defaultKaptcha; } } 复制代码
大体逻辑为,先查询验证码redis缓存,没有缓存则说明验证码缓存没有失效,返回错误。
查到没有验证码,则根据手机号码从数据库获得用户信息,生成一个4位的验证码,使用 rabbbitmq
队列把短信验证码保存到队列,同时加上手机验证码的redis缓存
/** * 发送验证码 * <p> * 1. 先去redis 查询是否 60S内已经发送 * 2. 未发送: 判断手机号是否存 ? false :产生4位数字 手机号-验证码 * 3. 发往消息中心-》发送信息 * 4. 保存redis * * @param mobile 手机号 * @return true、false */ @Override public R<Boolean> sendSmsCode(String mobile) { Object tempCode = redisTemplate.opsForValue().get(SecurityConstants.DEFAULT_CODE_KEY + mobile); if (tempCode != null) { log.error("用户:{}验证码未失效{}", mobile, tempCode); return new R<>(false, "验证码未失效,请失效后再次申请"); } SysUser params = new SysUser(); params.setPhone(mobile); List<SysUser> userList = this.selectList(new EntityWrapper<>(params)); if (CollectionUtil.isEmpty(userList)) { log.error("根据用户手机号{}查询用户为空", mobile); return new R<>(false, "手机号不存在"); } String code = RandomUtil.randomNumbers(4); JSONObject contextJson = new JSONObject(); contextJson.put("code", code); contextJson.put("product", "Pig4Cloud"); log.info("短信发送请求消息中心 -> 手机号:{} -> 验证码:{}", mobile, code); rabbitTemplate.convertAndSend(MqQueueConstant.MOBILE_CODE_QUEUE, new MobileMsgTemplate( mobile, contextJson.toJSONString(), CommonConstant.ALIYUN_SMS, EnumSmsChannelTemplate.LOGIN_NAME_LOGIN.getSignName(), EnumSmsChannelTemplate.LOGIN_NAME_LOGIN.getTemplate() )); redisTemplate.opsForValue().set(SecurityConstants.DEFAULT_CODE_KEY + mobile, code, SecurityConstants.DEFAULT_IMAGE_EXPIRE, TimeUnit.SECONDS); return new R<>(true); } 复制代码
public class TreeUtil { /** * 两层循环实现建树 * * @param treeNodes 传入的树节点列表 * @return */ public static <T extends TreeNode> List<T> bulid(List<T> treeNodes, Object root) { List<T> trees = new ArrayList<T>(); for (T treeNode : treeNodes) { if (root.equals(treeNode.getParentId())) { trees.add(treeNode); } for (T it : treeNodes) { if (it.getParentId() == treeNode.getId()) { if (treeNode.getChildren() == null) { treeNode.setChildren(new ArrayList<TreeNode>()); } treeNode.add(it); } } } return trees; } /** * 使用递归方法建树 * * @param treeNodes * @return */ public static <T extends TreeNode> List<T> buildByRecursive(List<T> treeNodes, Object root) { List<T> trees = new ArrayList<T>(); for (T treeNode : treeNodes) { if (root.equals(treeNode.getParentId())) { trees.add(findChildren(treeNode, treeNodes)); } } return trees; } /** * 递归查找子节点 * * @param treeNodes * @return */ public static <T extends TreeNode> T findChildren(T treeNode, List<T> treeNodes) { for (T it : treeNodes) { if (treeNode.getId() == it.getParentId()) { if (treeNode.getChildren() == null) { treeNode.setChildren(new ArrayList<TreeNode>()); } treeNode.add(findChildren(it, treeNodes)); } } return treeNode; } /** * 通过sysMenu创建树形节点 * * @param menus * @param root * @return */ public static List<MenuTree> bulidTree(List<SysMenu> menus, int root) { List<MenuTree> trees = new ArrayList<MenuTree>(); MenuTree node; for (SysMenu menu : menus) { node = new MenuTree(); node.setId(menu.getMenuId()); node.setParentId(menu.getParentId()); node.setName(menu.getName()); node.setUrl(menu.getUrl()); node.setPath(menu.getPath()); node.setCode(menu.getPermission()); node.setLabel(menu.getName()); node.setComponent(menu.getComponent()); node.setIcon(menu.getIcon()); trees.add(node); } return TreeUtil.bulid(trees, root); } } 复制代码
public class PigResourcesGenerator { public static void main(String[] args) { String outputDir = "/Users/lengleng/work/temp"; final String viewOutputDir = outputDir + "/view/"; AutoGenerator mpg = new AutoGenerator(); // 全局配置 GlobalConfig gc = new GlobalConfig(); gc.setOutputDir(outputDir); gc.setFileOverride(true); gc.setActiveRecord(true); // XML 二级缓存 gc.setEnableCache(false); // XML ResultMap gc.setBaseResultMap(true); // XML columList gc.setBaseColumnList(true); gc.setAuthor("lengleng"); mpg.setGlobalConfig(gc); // 数据源配置 DataSourceConfig dsc = new DataSourceConfig(); dsc.setDbType(DbType.MYSQL); dsc.setDriverName("com.mysql.jdbc.Driver"); dsc.setUsername("root"); dsc.setPassword("lengleng"); dsc.setUrl("jdbc:mysql://139.224.200.249:3309/pig?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false"); mpg.setDataSource(dsc); // 策略配置 StrategyConfig strategy = new StrategyConfig(); // strategy.setCapitalMode(true);// 全局大写命名 ORACLE 注意 strategy.setSuperControllerClass("com.github.pig.common.web.BaseController"); // 表名生成策略 strategy.setNaming(NamingStrategy.underline_to_camel); mpg.setStrategy(strategy); // 包配置 PackageConfig pc = new PackageConfig(); pc.setParent("com.github.pig.admin"); pc.setController("controller"); mpg.setPackageInfo(pc); // 注入自定义配置,可以在 VM 中使用 cfg.abc 设置的值 InjectionConfig cfg = new InjectionConfig() { @Override public void initMap() { } }; // 生成的模版路径,不存在时需要先新建 File viewDir = new File(viewOutputDir); if (!viewDir.exists()) { viewDir.mkdirs(); } List<FileOutConfig> focList = new ArrayList<FileOutConfig>(); focList.add(new FileOutConfig("/templates/listvue.vue.vm") { @Override public String outputFile(TableInfo tableInfo) { return getGeneratorViewPath(viewOutputDir, tableInfo, ".vue"); } }); cfg.setFileOutConfigList(focList); mpg.setCfg(cfg); //生成controller相关 mpg.execute(); } /** * 获取配置文件 * * @return 配置Props */ private static Properties getProperties() { // 读取配置文件 Resource resource = new ClassPathResource("/config/application.properties"); Properties props = new Properties(); try { props = PropertiesLoaderUtils.loadProperties(resource); } catch (IOException e) { e.printStackTrace(); } return props; } /** * 页面生成的文件名 */ private static String getGeneratorViewPath(String viewOutputDir, TableInfo tableInfo, String suffixPath) { String name = StringUtils.firstToLowerCase(tableInfo.getEntityName()); String path = viewOutputDir + "/" + name + "/index" + suffixPath; File viewDir = new File(path).getParentFile(); if (!viewDir.exists()) { viewDir.mkdirs(); } return path; } } 复制代码
velocity模板
package $!{package.Controller}; import java.util.Map; import java.util.Date; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import com.github.pig.common.constant.CommonConstant; import com.baomidou.mybatisplus.mapper.EntityWrapper; import com.baomidou.mybatisplus.plugins.Page; import com.github.pig.common.util.Query; import com.github.pig.common.util.R; import $!{package.Entity}.$!{entity}; import $!{package.Service}.$!{entity}Service; #if($!{superControllerClassPackage}) import $!{superControllerClassPackage}; #end /** * <p> * $!{table.comment} 前端控制器 * </p> * * @author $!{author} * @since $!{date} */ @RestController @RequestMapping("/$!{table.entityPath}") public class $!{table.controllerName} extends $!{superControllerClass} { @Autowired private $!{entity}Service $!{table.entityPath}Service; /** * 通过ID查询 * * @param id ID * @return $!{entity} */ @GetMapping("/{id}") public R<$!{entity}> get(@PathVariable Integer id) { return new R<>($!{table.entityPath}Service.selectById(id)); } /** * 分页查询信息 * * @param params 分页对象 * @return 分页对象 */ @RequestMapping("/page") public Page page(@RequestParam Map<String, Object> params) { params.put(CommonConstant.DEL_FLAG, CommonConstant.STATUS_NORMAL); return $!{table.entityPath}Service.selectPage(new Query<>(params), new EntityWrapper<>()); } /** * 添加 * @param $!{table.entityPath} 实体 * @return success/false */ @PostMapping public R<Boolean> add(@RequestBody $!{entity} $!{table.entityPath}) { return new R<>($!{table.entityPath}Service.insert($!{table.entityPath})); } /** * 删除 * @param id ID * @return success/false */ @DeleteMapping("/{id}") public R<Boolean> delete(@PathVariable Integer id) { $!{entity} $!{table.entityPath} = new $!{entity}(); $!{table.entityPath}.setId(id); $!{table.entityPath}.setUpdateTime(new Date()); $!{table.entityPath}.setDelFlag(CommonConstant.STATUS_DEL); return new R<>($!{table.entityPath}Service.updateById($!{table.entityPath})); } /** * 编辑 * @param $!{table.entityPath} 实体 * @return success/false */ @PutMapping public R<Boolean> edit(@RequestBody $!{entity} $!{table.entityPath}) { $!{table.entityPath}.setUpdateTime(new Date()); return new R<>($!{table.entityPath}Service.updateById($!{table.entityPath})); } } 复制代码
在部分实现类中,我们看到了作者使用了 spring cache
相关的注解。现在我们回忆一下相关缓存注解的含义:
@Cacheable
:用来定义缓存的。常用到是value,key;分别用来指明缓存的名称和方法中参数,对于value你也可以使用cacheName,在查看源代码是我们可以看到:两者是指的同一个东西。
@CacheEvict
:用来清理缓存。常用有cacheNames,allEntries(默认值false);分别代表了要清除的缓存名称和是否全部清除(true代表全部清除)。
@CachePut
:用来更新缓存,用它来注解的方法都会被执行,执行完后结果被添加到缓存中。该方法不能和@Cacheable同时在同一个方法上使用。
Elastic-Job
是ddframe中dd-job的作业模块中分离出来的分布式弹性作业框架。去掉了和dd-job中的监控和ddframe接入规范部分。该项目基于成熟的开源产品Quartz和Zookeeper及其客户端Curator进行二次开发。主要功能如下:
作者直接使用了开源项目的配置,我顺着他的pom文件找到了这家的github,地址如下
github.com/xjzrc/elast…
@ElasticJobConfig(cron = "0 0 0/1 * * ? ", shardingTotalCount = 3, shardingItemParameters = "0=Beijing,1=Shanghai,2=Guangzhou") public class PigDataflowJob implements DataflowJob<Integer> { @Override public List<Integer> fetchData(ShardingContext shardingContext) { return null; } @Override public void processData(ShardingContext shardingContext, List<Integer> list) { } } 复制代码
@Slf4j @ElasticJobConfig(cron = "0 0 0/1 * * ?", shardingTotalCount = 3, shardingItemParameters = "0=pig1,1=pig2,2=pig3", startedTimeoutMilliseconds = 5000L, completedTimeoutMilliseconds = 10000L, eventTraceRdbDataSource = "dataSource") public class PigSimpleJob implements SimpleJob { /** * 业务执行逻辑 * * @param shardingContext 分片信息 */ @Override public void execute(ShardingContext shardingContext) { log.info("shardingContext:{}", shardingContext); } } 复制代码
开源版对这个支持有限,等到拿到收费版我在做分析。
这里的消息中心主要是集成了钉钉服务和阿里大鱼短息服务
钉钉是相当简单了,只需要一个 webhook
信息就够了。
webhook
是一种web回调或者http的push API,是向APP或者其他应用提供实时信息的一种方式。Webhook在数据产生时立即发送数据,也就是你能实时收到数据。这一种不同于典型的API,需要用了实时性需要足够快的轮询。这无论是对生产还是对消费者都是高效的,唯一的缺点是初始建立困难。Webhook有时也被称为反向API,因为他提供了API规则,你需要设计要使用的API。Webhook将向你的应用发起http请求,典型的是post请求,应用程序由请求驱动。
@Data @Configuration @ConfigurationProperties(prefix = "sms.dingtalk") public class DingTalkPropertiesConfig { /** * webhook */ private String webhook; } 复制代码
/** * @author lengleng * @date 2018/1/15 * 钉钉消息模板 * msgtype : text * text : {"content":"服务: pig-upms-service 状态:UP"} */ @Data @ToString public class DingTalkMsgTemplate implements Serializable { private String msgtype; private TextBean text; public String getMsgtype() { return msgtype; } public void setMsgtype(String msgtype) { this.msgtype = msgtype; } public TextBean getText() { return text; } public void setText(TextBean text) { this.text = text; } public static class TextBean { /** * content : 服务: pig-upms-service 状态:UP */ private String content; public String getContent() { return content; } public void setContent(String content) { this.content = content; } } } 复制代码
使用队列时时监听
@Slf4j @Component @RabbitListener(queues = MqQueueConstant.DINGTALK_SERVICE_STATUS_CHANGE) public class DingTalkServiceChangeReceiveListener { @Autowired private DingTalkMessageHandler dingTalkMessageHandler; @RabbitHandler public void receive(String text) { long startTime = System.currentTimeMillis(); log.info("消息中心接收到钉钉发送请求-> 内容:{} ", text); dingTalkMessageHandler.process(text); long useTime = System.currentTimeMillis() - startTime; log.info("调用 钉钉网关处理完毕,耗时 {}毫秒", useTime); } } 复制代码
使用队列发送
@Slf4j @Component public class DingTalkMessageHandler { @Autowired private DingTalkPropertiesConfig dingTalkPropertiesConfig; /** * 业务处理 * * @param text 消息 */ public boolean process(String text) { String webhook = dingTalkPropertiesConfig.getWebhook(); if (StrUtil.isBlank(webhook)) { log.error("钉钉配置错误,webhook为空"); return false; } DingTalkMsgTemplate dingTalkMsgTemplate = new DingTalkMsgTemplate(); dingTalkMsgTemplate.setMsgtype("text"); DingTalkMsgTemplate.TextBean textBean = new DingTalkMsgTemplate.TextBean(); textBean.setContent(text); dingTalkMsgTemplate.setText(textBean); String result = HttpUtil.post(webhook, JSONObject.toJSONString(dingTalkMsgTemplate)); log.info("钉钉提醒成功,报文响应:{}", result); return true; } } 复制代码
@Data @Configuration @ConditionalOnExpression("!'${sms.aliyun}'.isEmpty()") @ConfigurationProperties(prefix = "sms.aliyun") public class SmsAliyunPropertiesConfig { /** * 应用ID */ private String accessKey; /** * 应用秘钥 */ private String secretKey; /** * 短信模板配置 */ private Map<String, String> channels; } 复制代码
@Slf4j @Component @RabbitListener(queues = MqQueueConstant.MOBILE_SERVICE_STATUS_CHANGE) public class MobileServiceChangeReceiveListener { @Autowired private Map<String, SmsMessageHandler> messageHandlerMap; @RabbitHandler public void receive(MobileMsgTemplate mobileMsgTemplate) { long startTime = System.currentTimeMillis(); log.info("消息中心接收到短信发送请求-> 手机号:{} -> 信息体:{} ", mobileMsgTemplate.getMobile(), mobileMsgTemplate.getContext()); String channel = mobileMsgTemplate.getChannel(); SmsMessageHandler messageHandler = messageHandlerMap.get(channel); if (messageHandler == null) { log.error("没有找到指定的路由通道,不进行发送处理完毕!"); return; } messageHandler.execute(mobileMsgTemplate); long useTime = System.currentTimeMillis() - startTime; log.info("调用 {} 短信网关处理完毕,耗时 {}毫秒", mobileMsgTemplate.getType(), useTime); } } 复制代码
不错的模板
@Slf4j @Component(CommonConstant.ALIYUN_SMS) public class SmsAliyunMessageHandler extends AbstractMessageHandler { @Autowired private SmsAliyunPropertiesConfig smsAliyunPropertiesConfig; private static final String PRODUCT = "Dysmsapi"; private static final String DOMAIN = "dysmsapi.aliyuncs.com"; /** * 数据校验 * * @param mobileMsgTemplate 消息 */ @Override public void check(MobileMsgTemplate mobileMsgTemplate) { Assert.isBlank(mobileMsgTemplate.getMobile(), "手机号不能为空"); Assert.isBlank(mobileMsgTemplate.getContext(), "短信内容不能为空"); } /** * 业务处理 * * @param mobileMsgTemplate 消息 */ @Override public boolean process(MobileMsgTemplate mobileMsgTemplate) { //可自助调整超时时间 System.setProperty("sun.net.client.defaultConnectTimeout", "10000"); System.setProperty("sun.net.client.defaultReadTimeout", "10000"); //初始化acsClient,暂不支持region化 IClientProfile profile = DefaultProfile.getProfile("cn-hangzhou", smsAliyunPropertiesConfig.getAccessKey(), smsAliyunPropertiesConfig.getSecretKey()); try { DefaultProfile.addEndpoint("cn-hou", "cn-hangzhou", PRODUCT, DOMAIN); } catch (ClientException e) { log.error("初始化SDK 异常", e); e.printStackTrace(); } IAcsClient acsClient = new DefaultAcsClient(profile); //组装请求对象-具体描述见控制台-文档部分内容 SendSmsRequest request = new SendSmsRequest(); //必填:待发送手机号 request.setPhoneNumbers(mobileMsgTemplate.getMobile()); //必填:短信签名-可在短信控制台中找到 request.setSignName(mobileMsgTemplate.getSignName()); //必填:短信模板-可在短信控制台中找到 request.setTemplateCode(smsAliyunPropertiesConfig.getChannels().get(mobileMsgTemplate.getTemplate())); //可选:模板中的变量替换JSON串,如模板内容为"亲爱的${name},您的验证码为${code}" request.setTemplateParam(mobileMsgTemplate.getContext()); request.setOutId(mobileMsgTemplate.getMobile()); //hint 此处可能会抛出异常,注意catch try { SendSmsResponse sendSmsResponse = acsClient.getAcsResponse(request); log.info("短信发送完毕,手机号:{},返回状态:{}", mobileMsgTemplate.getMobile(), sendSmsResponse.getCode()); } catch (ClientException e) { log.error("发送异常"); e.printStackTrace(); } return true; } /** * 失败处理 * * @param mobileMsgTemplate 消息 */ @Override public void fail(MobileMsgTemplate mobileMsgTemplate) { log.error("短信发送失败 -> 网关:{} -> 手机号:{}", mobileMsgTemplate.getType(), mobileMsgTemplate.getMobile()); } } 复制代码
由于作者在认证中心使用了spring security oauth框架,所以需要在微服务的客户端实现一个资源认证服务器,来完成SSO需求。
暴露监控信息
@Configuration @EnableResourceServer public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter { @Override public void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .anyRequest().authenticated() .and() .csrf().disable(); } } 复制代码
@EnableOAuth2Sso @SpringBootApplication public class PigSsoClientDemoApplication { public static void main(String[] args) { SpringApplication.run(PigSsoClientDemoApplication.class, args); } } 复制代码
RemindingNotifier
会在应用上线或宕掉的时候发送提醒,也就是把 notifications
发送给其他的 notifier
,notifier的实现很有意思,不深究了,从类关系可以知道,我们可以以这么几种方式发送notifications:Pagerduty、Hipchat 、Slack 、Mail、 Reminder
@Configuration public static class NotifierConfig { @Bean @Primary public RemindingNotifier remindingNotifier() { RemindingNotifier notifier = new RemindingNotifier(filteringNotifier(loggerNotifier())); notifier.setReminderPeriod(TimeUnit.SECONDS.toMillis(10)); return notifier; } @Scheduled(fixedRate = 1_000L) public void remind() { remindingNotifier().sendReminders(); } @Bean public FilteringNotifier filteringNotifier(Notifier delegate) { return new FilteringNotifier(delegate); } @Bean public LoggingNotifier loggerNotifier() { return new LoggingNotifier(); } } 复制代码
继承 AbstractStatusChangeNotifier
,将短信服务注册到 spring boot admin
中。
@Slf4j public class StatusChangeNotifier extends AbstractStatusChangeNotifier { private RabbitTemplate rabbitTemplate; private MonitorPropertiesConfig monitorMobilePropertiesConfig; public StatusChangeNotifier(MonitorPropertiesConfig monitorMobilePropertiesConfig, RabbitTemplate rabbitTemplate) { this.rabbitTemplate = rabbitTemplate; this.monitorMobilePropertiesConfig = monitorMobilePropertiesConfig; } /** * 通知逻辑 * * @param event 事件 * @throws Exception 异常 */ @Override protected void doNotify(ClientApplicationEvent event) { if (event instanceof ClientApplicationStatusChangedEvent) { log.info("Application {} ({}) is {}", event.getApplication().getName(), event.getApplication().getId(), ((ClientApplicationStatusChangedEvent) event).getTo().getStatus()); String text = String.format("应用:%s 服务ID:%s 状态改变为:%s,时间:%s" , event.getApplication().getName() , event.getApplication().getId() , ((ClientApplicationStatusChangedEvent) event).getTo().getStatus() , DateUtil.date(event.getTimestamp()).toString()); JSONObject contextJson = new JSONObject(); contextJson.put("name", event.getApplication().getName()); contextJson.put("seid", event.getApplication().getId()); contextJson.put("time", DateUtil.date(event.getTimestamp()).toString()); //开启短信通知 if (monitorMobilePropertiesConfig.getMobile().getEnabled()) { log.info("开始短信通知,内容:{}", text); rabbitTemplate.convertAndSend(MqQueueConstant.MOBILE_SERVICE_STATUS_CHANGE, new MobileMsgTemplate( CollUtil.join(monitorMobilePropertiesConfig.getMobile().getMobiles(), ","), contextJson.toJSONString(), CommonConstant.ALIYUN_SMS, EnumSmsChannelTemplate.SERVICE_STATUS_CHANGE.getSignName(), EnumSmsChannelTemplate.SERVICE_STATUS_CHANGE.getTemplate() )); } if (monitorMobilePropertiesConfig.getDingTalk().getEnabled()) { log.info("开始钉钉通知,内容:{}", text); rabbitTemplate.convertAndSend(MqQueueConstant.DINGTALK_SERVICE_STATUS_CHANGE, text); } } else { log.info("Application {} ({}) {}", event.getApplication().getName(), event.getApplication().getId(), event.getType()); } } } 复制代码
由于zipkin是侵入式,因此这部分组件没有代码,只有相关依赖。下面分享一下作者的yaml
server: port: 5003 # datasoure默认使用JDBC spring: datasource: driver-class-name: com.mysql.jdbc.Driver username: root password: ENC(gc16brBHPNq27HsjaULgKGq00Rz6ZUji) url: jdbc:mysql://127.0.0.1:3309/pig?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false zipkin: collector: rabbitmq: addresses: 127.0.0.1:5682 password: lengleng username: pig queue: zipkin storage: type: mysql 复制代码
server: port: 5002 zipkin: collector: rabbitmq: addresses: 127.0.0.1:5682 password: lengleng username: pig queue: zipkin storage: type: elasticsearch elasticsearch: hosts: 127.0.0.1:9200 cluster: elasticsearch index: zipkin max-requests: 64 index-shards: 5 index-replicas: 1 复制代码