看spring cloud開源專案Pig的雲踩坑記

tengshe789發表於2018-12-12

最近看到一個有趣的開源專案pig,主要的技術點在認證授權中心,spring security oauth,zuul閘道器實現,Elastic-Job定時任務,趁著剛剛入門微服務,趕快寫個部落格分析一下。此篇文章主要用於個人備忘。如果有不對,請批評。?

由於每個模組篇幅較長,且部分內容和前文有重疊,乾貨和圖片較少,閱讀時使用旁邊的導航功能體驗較佳。?

想要解鎖更多新姿勢?請訪問https://blog.tengshe789.tech/

說明

本篇文章是對基於spring boot1.5的pig 1版本做的分析,不是收費的pigx 2版本。

開源專案地址

gitee.com/log4j/pig

配置中心:gitee.com/cqzqxq_lxh/…

冷冷官方地址

pig4cloud.com/zh-cn/index…

體驗地址

pigx.pig4cloud.com/#/wel/index

專案啟動順序

請確保啟動順序(要先啟動認證中心,再啟動閘道器

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

認證中心

老規矩,自上到下看程式碼,先從介面層看起

請求rest介面

@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,我們來一探究竟。

使用spring security 實現 授權伺服器

註明,閱讀此處模組需要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聯結器,查詢客戶端的詳細資訊?

	@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增強器(自定義token資訊中攜帶的資訊)

有時候需要額外的資訊加到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資訊中新增的資訊)

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);
    }
}
複製程式碼
redis與token

使用鑑權的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);
    }
}
複製程式碼

手機號登入校驗邏輯MobileAuthenticationProvider

spring security 中,AuthenticationManage管理一系列的AuthenticationProvider, 而每一個Provider都會通UserDetailsServiceUserDetail來返回一個 以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

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();
    }
}
複製程式碼

手機號登入驗證filter

判斷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);
    }
複製程式碼

手機登陸成功的處理器MobileLoginSuccessHandler

這個處理器可以返回手機號登入成功的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)};
    }
複製程式碼

其他配置

redis叢集

挺好的模板,收藏一下

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,數字越大優先順序越低。

XSS過濾器

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() {
···略
            }
        };
    }
複製程式碼

密碼過濾器DecodePasswordFilter

此過濾器優先順序為+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;
    }
複製程式碼

校驗碼過濾器ValidateCodeFilter

邏輯作者都寫在註釋中了,此處使用了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();
    }
}
複製程式碼

灰度釋出有關過濾器AccessFilter

首先重寫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

路由微服務斷言處理器MetadataCanaryRuleHandler

自定義ribbon路由規則匹配多版本請求,實現灰度釋出。複合判斷server所在區域的效能和server的可用性選擇server,即,使用ZoneAvoidancePredicate和AvailabilityPredicate來判斷是否選擇某個server,前一個判斷判定一個zone的執行效能是否可用,剔除不可用的zone(的所有server),AvailabilityPredicate用於過濾掉連線數過多的Server。

此處邏輯是

  1. eureka metadata (主機名,IP地址,埠號,狀態頁健康檢查等資訊,或者通過配置檔案自定義後設資料)存在版本定義時候進行判斷
  2. 不存在 metadata 直接返回true
@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);
        }
    }
}
複製程式碼

多維度限流

限流降級處理器ZuulRateLimiterErrorHandler

重寫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);
            }
        };
    }
複製程式碼

與spring security oAuth方法整合單點登陸

授權拒絕處理器 PigAccessDeniedHandler

重寫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));
    }
複製程式碼

選單管理

MenuService

@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許可權管理系統模組

百度了一下,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);
    }
}
複製程式碼

生成avue模板類

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進行二次開發。主要功能如下:

  • 定時任務: 基於成熟的定時任務作業框架Quartz cron表示式執行定時任務。
  • 作業註冊中心: 基於Zookeeper和其客戶端Curator實現的全域性作業註冊控制中心。用於註冊,控制和協調分散式作業執行。
  • 作業分片: 將一個任務分片成為多個小任務項在多伺服器上同時執行。
  • 彈性擴容縮容: 執行中的作業伺服器崩潰,或新增加n臺作業伺服器,作業框架將在下次作業執行前重新分片,不影響當前作業執行。
  • 支援多種作業執行模式: 支援OneOff,Perpetual和SequencePerpetual三種作業模式。
  • 失效轉移: 執行中的作業伺服器崩潰不會導致重新分片,只會在下次作業啟動時分片。啟用失效轉移功能可以在本次作業執行過程中,監測其他作業伺服器空閒,抓取未完成的孤兒分片項執行。
  • 執行時狀態收集: 監控作業執行時狀態,統計最近一段時間處理的資料成功和失敗數量,記錄作業上次執行開始時間,結束時間和下次執行時間。
  • **作業停止,恢復和禁用:**用於操作作業啟停,並可以禁止某作業執行(上線時常用)。
  • **被錯過執行的作業重觸發:**自動記錄錯過執行的作業,並在上次作業完成後自動觸發。可參考Quartz的misfire。
  • **多執行緒快速處理資料:**使用多執行緒處理抓取到的資料,提升吞吐量。
  • **冪等性:**重複作業任務項判定,不重複執行已執行的作業任務項。由於開啟冪等性需要監聽作業執行狀態,對瞬時反覆執行的作業對效能有較大影響。
  • **容錯處理:**作業伺服器與Zookeeper伺服器通訊失敗則立即停止作業執行,防止作業註冊中心將失效的分片分項配給其他作業伺服器,而當前作業伺服器仍在執行任務,導致重複執行。
  • **Spring支援:**支援spring容器,自定義名稱空間,支援佔位符。
  • **運維平臺:**提供運維介面,可以管理作業和註冊中心。

配置

作者直接使用了開源專案的配置,我順著他的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);
    }

}
複製程式碼

監控模組

springboot admin配置

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 鏈路追蹤

由於zipkin是侵入式,因此這部分元件沒有程式碼,只有相關依賴。下面分享一下作者的yaml

DB

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

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時間

全片結束,覺得我寫的不錯?想要了解更多精彩新姿勢?趕快開啟我的?個人部落格 ?吧!

謝謝你那麼可愛,還一直關注著我~❤?

相關文章