看Pig开源项目的云踩坑记

最近看到一个有趣的开源项目pig,主要的技术点在认证授权中心,spring security oauth,zuul网关实现,Elastic-Job定时任务,趁着刚刚入门微服务,赶快写个博客分析一下。此篇文章主要用于个人备忘。如果有不对,请批评。😭

由于每个模块篇幅较长,且部分内容和前文有重叠,干货和图片较少,阅读时使用旁边的导航功能体验较佳。😉

想要解锁更多新姿势?请访问https://blog.tengshe789.tech/

说明

本篇文章是对基于spring boot1.5的pig 1版本做的分析,不是收费的pigx 2版本。

开源项目地址

https://gitee.com/log4j/pig

配置中心:https://gitee.com/cqzqxq_lxh/pig-config

冷冷官方地址

https://pig4cloud.com/zh-cn/index.html

体验地址

http://pigx.pig4cloud.com/#/wel/index

项目启动顺序

请确保启动顺序(要先启动认证中心,再启动网关

  1. eureka
  2. config
  3. auth
  4. gateway
  5. upms

认证中心

老规矩,自上到下看代码,先从接口层看起

请求rest接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@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过滤)。使用手机登陆配置啦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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,我们来一探究竟。

使用spring security 实现 授权服务器

注明,阅读此处模块需要OAUTH基础,https://blog.tengshe789.tech/2018/12/02/%E6%84%9F%E6%80%A7%E8%AE%A4%E8%AF%86jwt/#more

这里简单提一下,spring security oauth里有两个概念,授权服务器和资源服务器。

授权服务器是根据授权许可给访问的客户端发放access token令牌的,提供认证、授权服务;

资源服务器需要验证这个access token,客户端才能访问对应服务。

客户详细信息服务配置

ClientDetailsServiceConfigurer(AuthorizationServerConfigurer 的一个回调配置项) 能够使用内存或者JDBC来实现客户端详情服务(ClientDetailsService),Spring Security OAuth2的配置方法是编写@Configuration类继承AuthorizationServerConfigurerAdapter,然后重写void configure(ClientDetailsServiceConfigurer clients)方法

下面代码主要逻辑是,使用spring security框架封装的简单sql连接器,查询客户端的详细信息👇

1
2
3
4
5
6
7
@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声明语句改了一改,方面阅读:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 默认的查询语句
*/
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中进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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增强器(自定义token信息中携带的信息)

有时候需要额外的信息加到token返回中,这部分也可以自定义,此时我们可以自定义一个TokenEnhancer,来自定义生成token携带的信息。TokenEnhancer接口提供一个 enhance(OAuth2AccessToken var1, OAuth2Authentication var2) 方法,用于对token信息的添加,信息来源于OAuth2Authentication

作者将生成的accessToken中,加上了自己的名字,加上了userId

1
2
3
4
5
6
7
8
9
10
11
12
13
@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信息中添加的信息)

JWT中,需要在token中携带额外的信息,这样可以在服务之间共享部分用户信息,spring security默认在JWT的token中加入了user_name,如果我们需要额外的信息,需要自定义这部分内容。

JwtAccessTokenConverter是使用JWT替换默认的Token的转换器,而token令牌默认是有签名的,且资源服务器需要验证这个签名。此处的加密及验签包括两种方式:

  • 对称加密

  • 非对称加密(公钥密钥)

对称加密需要授权服务器和资源服务器存储同一key值,而非对称加密可使用密钥加密,暴露公钥给资源服务器验签

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

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);
}
}
redis与token

使用鉴权的endpoint将加上自己名字的token放入redis,redis连接器用的srping data redis框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 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;
}

授权服务器安全配置器

1
2
3
4
5
6
7
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
.allowFormAuthenticationForClients()
.tokenKeyAccess("isAuthenticated()")
.checkTokenAccess("permitAll()");
}

自定义实现的手机号 认证服务

接口层

先看接口层,这里和pig-upms-service联动,给了三个路径,用户使用手机号码登陆可通过三个路径发送请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@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方式保存。拿到信息后,使用过滤器验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@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);
}
}

手机号登录校验逻辑MobileAuthenticationProvider

spring security 中,AuthenticationManage管理一系列的AuthenticationProvider
而每一个Provider都会通UserDetailsServiceUserDetail来返回一个
MobileAuthenticationToken实现的带用户以及权限的Authentication

此处逻辑是,通过UserService查找已有用户的手机号码,生成对应的UserDetails,使用UserDetails生成手机验证Authentication

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@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

MobileAuthenticationToken继承AbstractAuthenticationToken实现Authentication
所以当在页面中输入手机之后首先会进入到MobileAuthenticationToken验证(Authentication),
然后生成的Authentication会被交由我上面说的AuthenticationManager来进行管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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();
}
}

手机号登录验证filter

判断http请求是否是post,不是则返回错误。

根据request请求拿到moblie信息,使用moblie信息返回手机号码登陆成功的oauth token。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@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);
}

手机登陆成功的处理器MobileLoginSuccessHandler

这个处理器可以返回手机号登录成功的oauth token,但是要将oauth token传输出去必须配合上面的手机号登录验证filter

逻辑都在注释中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@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)};
}

其他配置

redis集群

挺好的模板,收藏一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
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组件的核心是一系列的过滤器,我们先从过滤器下手。

网关统一异常过滤器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@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,数字越大优先级越低。

XSS过滤器

1
2
3
4
5
6
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`为过滤链条。

1
2
3
4
5
6
7
8
9
10
11
@Override
public ServletInputStream getInputStream() throws IOException {
····略
//xss过滤
json = xssEncode(json);
final ByteArrayInputStream bis = new ByteArrayInputStream(json.getBytes("utf-8"));
return new ServletInputStream() {
···略
}
};
}

密码过滤器DecodePasswordFilter

此过滤器优先级为+2.每当一个请求不是请求/oauth/token或者/mobile/token这个地址时,都会解析使用aes解码器password

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@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;
}

校验码过滤器ValidateCodeFilter

逻辑作者都写在注释中了,此处使用了redis做为服务端验证码的缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
**
* 是否校验验证码
* 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系统.

初始化

下面是灰度路由初始化类:

1
2
3
4
5
6
7
8
9
10
11
12
13
@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();
}
}

灰度发布有关过滤器AccessFilter

首先重写filterOrder()方法,使这个过滤器在在RateLimitPreFilter之前运行,不会出现空指针问题。此处优先级FORM_BODY_WRAPPER_FILTER_ORDER-1.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@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

路由微服务断言处理器MetadataCanaryRuleHandler

自定义ribbon路由规则匹配多版本请求,实现灰度发布。复合判断server所在区域的性能和server的可用性选择server,即,使用ZoneAvoidancePredicate和AvailabilityPredicate来判断是否选择某个server,前一个判断判定一个zone的运行性能是否可用,剔除不可用的zone(的所有server),AvailabilityPredicate用于过滤掉连接数过多的Server。

此处逻辑是

  1. eureka metadata (主机名,IP地址,端口号,状态页健康检查等信息,或者通过配置文件自定义元数据)存在版本定义时候进行判断
  2. 不存在 metadata 直接返回true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@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;
}
}
};
}

动态路由

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
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;
}
}

网关日志处理

代码注释已经将逻辑写的很清楚了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@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);
}
}
}

多维度限流

限流降级处理器ZuulRateLimiterErrorHandler

重写zuul中默认的限流处理器DefaultRateLimiterErrorHandler,使之记录日志内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@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);
}
};
}

与spring security oAuth方法整合单点登陆

授权拒绝处理器 PigAccessDeniedHandler

重写Srping security oAuth 提供单点登录验证拒绝OAuth2AccessDeniedHandler接口,使用R包装失败信息到PigDeniedException

1
2
3
4
5
6
7
8
9
10
@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));
}

菜单管理

1
2
3
4
5
6
7
8
9
10
11
@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系统的菜单微服务

菜单权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@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权限管理系统模块

百度了一下,UPMS是User Permissions Management System,通用用户权限管理系统

数据库设计

部门表

服务网关

部门关系表

服务网关

字典表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 编号
*/
@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;

日志表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@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; }}

菜单权限表

服务网关

角色表

服务网关

角色与部门对应关系

角色与菜单权限对应关系

用户表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* 主键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可以找到创建验证码相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 创建验证码
*
* @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,下面是配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@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缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 发送验证码
* <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);
}

树形节点工具栏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
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);
}
}

生成avue模板类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
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模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
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进行二次开发。主要功能如下:

  • 定时任务: 基于成熟的定时任务作业框架Quartz cron表达式执行定时任务。
  • 作业注册中心: 基于Zookeeper和其客户端Curator实现的全局作业注册控制中心。用于注册,控制和协调分布式作业执行。
  • 作业分片: 将一个任务分片成为多个小任务项在多服务器上同时执行。
  • 弹性扩容缩容: 运行中的作业服务器崩溃,或新增加n台作业服务器,作业框架将在下次作业执行前重新分片,不影响当前作业执行。
  • 支持多种作业执行模式: 支持OneOff,Perpetual和SequencePerpetual三种作业模式。
  • 失效转移: 运行中的作业服务器崩溃不会导致重新分片,只会在下次作业启动时分片。启用失效转移功能可以在本次作业执行过程中,监测其他作业服务器空闲,抓取未完成的孤儿分片项执行。
  • 运行时状态收集: 监控作业运行时状态,统计最近一段时间处理的数据成功和失败数量,记录作业上次运行开始时间,结束时间和下次运行时间。
  • 作业停止,恢复和禁用:用于操作作业启停,并可以禁止某作业运行(上线时常用)。
  • 被错过执行的作业重触发:自动记录错过执行的作业,并在上次作业完成后自动触发。可参考Quartz的misfire。
  • 多线程快速处理数据:使用多线程处理抓取到的数据,提升吞吐量。
  • 幂等性:重复作业任务项判定,不重复执行已运行的作业任务项。由于开启幂等性需要监听作业运行状态,对瞬时反复运行的作业对性能有较大影响。
  • 容错处理:作业服务器与Zookeeper服务器通信失败则立即停止作业运行,防止作业注册中心将失效的分片分项配给其他作业服务器,而当前作业服务器仍在执行任务,导致重复执行。
  • Spring支持:支持spring容器,自定义命名空间,支持占位符。
  • 运维平台:提供运维界面,可以管理作业和注册中心。

配置

作者直接使用了开源项目的配置,我顺着他的pom文件找到了这家的github,地址如下

https://github.com/xjzrc/elastic-job-lite-spring-boot-starter

工作流作业配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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) {

}
}

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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请求,应用程序由请求驱动。

1
2
3
4
5
6
7
8
9
@Data
@Configuration
@ConfigurationProperties(prefix = "sms.dingtalk")
public class DingTalkPropertiesConfig {
/**
* webhook
*/
private String webhook;
}

消息模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* @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;
}
}
}

监听

使用队列时时监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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);
}
}

发送

使用队列发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@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;
}
}

阿里大鱼短息服务

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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;
}

监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@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);
}
}

发送

不错的模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@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需求。

配置

暴露监控信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

@Override
public void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.csrf().disable();
}

}

接口

1
2
3
4
5
6
7
8
9
@EnableOAuth2Sso
@SpringBootApplication
public class PigSsoClientDemoApplication {

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

}

监控模块

springboot admin配置

RemindingNotifier会在应用上线或宕掉的时候发送提醒,也就是把notifications发送给其他的notifier,notifier的实现很有意思,不深究了,从类关系可以知道,我们可以以这么几种方式发送notifications:Pagerduty、Hipchat 、Slack 、Mail、 Reminder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@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中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@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 链路追踪

由于zipkin是侵入式,因此这部分组件没有代码,只有相关依赖。下面分享一下作者的yaml

DB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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

ELK

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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

续1s时间

全片结束,觉得我写的不错?想要了解更多精彩新姿势?赶快打开我的👉个人博客 👈吧!

谢谢你那么可爱,还一直关注着我~❤😝

-------------本稿が終わる感谢您的阅读-------------