深入理解Spring Cloud Zuul网关和使用场景

导读:本篇文章讲解 深入理解Spring Cloud Zuul网关和使用场景,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

网关概述

简单的理解,网关主要功能就是过滤路由转发,统一了对后端服务的访问。网关基于ZuulServlet,定义了一组ZuulFilter过滤器实现各类拦截逻辑,ZuulFilter定义了pre,route,post,err四种类型。ZuulServlet的service方法源码如下:

// ZuulServlet.java
    @Override
    public void service(javax.servlet.ServletRequest servletRequest, javax.servlet.ServletResponse servletResponse) throws ServletException, IOException {
        try {
            init((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse);

            RequestContext context = RequestContext.getCurrentContext();
            context.setZuulEngineRan();

            try {
// pre阶段
                preRoute();
            } catch (ZuulException e) {
// error
                error(e);
// 报错以后,先执行error,然后执行post
                postRoute();
                return;
            }
            try {
// route 路由阶段
                route();
            } catch (ZuulException e) {
                error(e);
                postRoute();
                return;
            }
            try {
                postRoute();
            } catch (ZuulException e) {
                error(e);
                return;
            }

        } catch (Throwable e) {
            error(new ZuulException(e, 500, "UNHANDLED_EXCEPTION_" + e.getClass().getName()));
        } finally {
            RequestContext.getCurrentContext().unset();
        }
    }

ZuulServlet在启动时,默认配置许多ZuulFilter,定义RequestContext上下文保存各个过滤器对请求参数的操作。以下主要讲解下PreDecorationFilter和RibbonRoutingFilter的功能。

  • PreDecorationFilter,把ZuulProperties(zuul配置文件映射的值)转换到RequestContext,默认屏蔽了authenrization,cookie等header向下游请求传递。
  • RibbonRoutingFilter,使用httpclient通过服务注册中心负载均衡的转发匹配的服务。

zuul网关的应用

zuul网关所有服务都是通过ZuulFilter来实现的,包括路由转发,认证授权,限流,降级,防止用户重复提交等功能。以下举例说明常见业务的实现。

路由转发

在zuul的路由转发功能,由如下过滤器具体实现:

  • SimpleHostRoutingFilter,直接转换host地址。
  • SendForwardFilter,本地url跳转。
  • RibbonRoutingFilter,基于服务注册与发现,动态的路由转发。

路由常见的使用情况如下:

  1. zuul天然的与服务注册中心(eureka服务注册与发现)集成,通过ribbon负载均衡到映射的服务。
  2. 在集成老的业务系统到网关时,由于没有使用服务注册与发现组件,需要直接配置映射地址。这时,zuul可以使用基于数据库的数据,实现动态路由。具体实现可以参考阿里大神写的springcloud—-Zuul 动态路由。大致两方面的内容,容器启动时,路由数据的动态获取,以及运行时,基于Spring 的事件发布机制动态(可以参考Spring事件发布机制-Tomcat教你如何玩)刷新路由信息。

动态路由的部分实现代码如下:

public class CustomRouteLocator extends SimpleRouteLocator implements RefreshableRouteLocator {

    private ZuulProperties properties;

    public CustomRouteLocator(String servletPath, ZuulProperties properties) {
        super(servletPath, properties);
        this.properties = properties;
    }

    @Override
    public void refresh() {
        doRefresh();
    }

    @Override
    protected Map<String, ZuulProperties.ZuulRoute> locateRoutes() {
        LinkedHashMap<String, ZuulProperties.ZuulRoute> routesMap = new LinkedHashMap<>();
        //从application.properties中加载路由信息
        routesMap.putAll(super.locateRoutes());
        //从db中加载路由信息
        routesMap.putAll(locateRoutesFromDB());
        //优化一下配置
        LinkedHashMap<String, ZuulProperties.ZuulRoute> values = new LinkedHashMap<>();
        for (Map.Entry<String, ZuulProperties.ZuulRoute> entry : routesMap.entrySet()) {
            String path = entry.getKey();
            // Prepend with slash if not already present.
            if (!path.startsWith("/")) {
                path = "/" + path;
            }
            if (StringUtils.hasText(this.properties.getPrefix())) {
                path = this.properties.getPrefix() + path;
                if (!path.startsWith("/")) {
                    path = "/" + path;
                }
            }
            values.put(path, entry.getValue());
        }
        return values;
    }

    private Map<String, ZuulProperties.ZuulRoute> locateRoutesFromDB() {
        Map<String, ZuulProperties.ZuulRoute> routes = new LinkedHashMap<>();
        // TODO 从数据库获取配置
        return routes;
    }
}

运行时,基于Spring事件发布机制动态刷新的代码如下:

@Service
public class RefreshRouteService {

    @Autowired
    private ApplicationEventPublisher publisher;

    @Autowired
    private RouteLocator routeLocator;

    /**
     * 刷新路由
     */
    public void refreshRoute() {

        RoutesRefreshedEvent routesRefreshedEvent = new RoutesRefreshedEvent(routeLocator);
        publisher.publishEvent(routesRefreshedEvent);
    }
}

身份认证

在微服务中,一般基于OAuth2协议进行认证和授权,客户端向授权服务进行身份认证,认证通过后获取访问授权码(access token);然后,携带访问授权码(access token)访问指定资源服务器,资源授权通过以后就可以访问到请求的资源。此时,把资源授权的逻辑统一在网关处理,就避免在各个下游服务重复的做授权处理。基于spring cloud security实现 oauth2协议的具体流程实现可以参考玩转Spring Cloud Security OAuth2资源授权动态权限扩展

这里,重点讲解下,在网关授权成功以后,只能在网关服务中,通过认证上下文获取到当前认证用户的信息,网关转发的各个下游服务是获取不到当前登录用户信息的。这时有两种思路实现:

  1. 在认证的access token中,存储当前用户信息,只要下游的微服务拿到access token做一个解析就可以拿到认证用户信息。
  2. 首先,在认证服务器创建一个可以根据access token获取当前认证用户的api。网关把access token转发到下游微服务,在下游的微服务中,实现一个servlet 过滤器,根据access token从认证服务器的api获取用户信息,并存在一个用户上下文中即可。

那么zuul网关该如何转发访问授权码(access token)呢?有两种方法可以实现:

1. 开启屏蔽的header配置,因为,在PreDecorationFilter过滤器中,默认屏蔽了Authenrization转发到下游服务,配置如下:

zuul:
  sensitive-headers:

sensitive-headers属性为空,就会屏蔽部分默认的header,其中就包括用于授权的Authenrization头信息。

2. 自定义pre类型的GlobalFilter ,在header中将Authenrization转换access_token然后再转发,GlobalFilter 的run方法代码如下:

// GlobalFilter.java 
    @Override
    public Object run() throws ZuulException {

        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();
        String accessToken;
        // 把Authenrization转换为accessToken
        if (StringUtils.isNotBlank(accessToken = extractHeaderToken(request))) {
            ctx.addZuulRequestHeader("access_token", accessToken);
        }
        return null;
    }

服务限流

在微服务中,对于访问非常频繁,超出了服务器的极限,可以在网关对该api进行限流。在并发比较高的场景,例如秒杀场景,可以使用令牌桶法则进行限流。令牌桶法则,可以在调用前计算出当前时间可用的令牌,该特性使其适用于有流量陡增的场景,获取令牌时,如果有则访问成功,反之则失败。示例,使用gava中的令牌桶法则实现类RateLimiter,对映射的api进行限流,代码如下:

public class RequestsLimitFilter extends ZuulFilter implements InitializingBean, ApplicationContextAware {

    private final AntPathMatcher antPathMatcher = new AntPathMatcher();

    private ApplicationContext applicationContext;

    /**
     * 服务限流配置
     */
    private List<RequestsLimitConfig> requestsLimitConfigs;

    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return 11;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {

        RequestContext ctx = RequestContext.getCurrentContext();
        try {

            if (CollectionUtils.isEmpty(requestsLimitConfigs)) {

                log.info("no requests limit configs!");
                return null;
            }
            HttpServletRequest request = ctx.getRequest();
            for (RequestsLimitConfig requestsLimitConfig : requestsLimitConfigs) {
                // 匹配的请求
                String urlPattern;
                RateLimiter rateLimiter;
                if (StringUtils.isNotEmpty(urlPattern = requestsLimitConfig.urlPattern)
                        && antPathMatcher.match(urlPattern, RequestUtils.getRequestUriIfRemovePreServiceId(request))
                        && request.getMethod().equalsIgnoreCase(requestsLimitConfig.getLimitMethod())
                        && Objects.nonNull(rateLimiter = requestsLimitConfig.rateLimiter)) {
                    // 使用令牌桶法则,尝试获取令牌
                    boolean success = rateLimiter.tryAcquire(1, TimeUnit.SECONDS);
                    if (!success) {

                        log.warn("limit request!url {},method {}", request.getRequestURI(), request.getMethod());
                        // 不走路由,RibbonRoutingFilter shouldFilter 判断条件之一
                        ctx.setSendZuulResponse(false);
                        ctx.setResponseStatusCode(HttpStatus.TOO_MANY_REQUESTS.value());
                        ctx.setResponseBody(HttpStatus.TOO_MANY_REQUESTS.toString());
                    }
                }
            }
        } catch (Exception e) {

            String errorMsg = "RequestsLimitFilter error when run!";
            log.error(errorMsg, e);
            WebUtils.responseOutJson(ctx.getResponse(), JSON.toJSONString(com.kuqi.mall.common.response.Response.builder().code(500).message(errorMsg).build()));
        }
        return null;
    }

    @Override
    public void afterPropertiesSet() throws Exception {

        // spring bean注入时,获取服务限流配置
        RequestsLimitService requestsLimitService = applicationContext.getBean(RequestsLimitService.class);
        if (Objects.nonNull(requestsLimitService)) {
            this.requestsLimitConfigs = requestsLimitService.findRequestsLimitList();
        }
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    /**
     * 限流配置
     */
    @Data
    public static class RequestsLimitConfig implements Serializable {

        private static final long serialVersionUID = 3477902563524091219L;

        /**
         * 拦截url的pattern
         */
        private String urlPattern;
        /**
         * 拦截方法
         */
        private String limitMethod;
        /***
         * 令牌桶过滤器
         */
        private RateLimiter rateLimiter;
    }
}

用户频率限制

对于类似于下单的场景,如果在秒杀场景,同一个用户频繁的点击下单操作,对服务器性能时一个挑战。这时,可以在网关中,定义匹配服务的用户频率操作限制,防止用户重复提交。示例中,通过redis string类型的setIfAbsent方法,如果不存在对应的key的值,则设置成功,并且其失效时间就标识频繁操作的间隔时间。示例,根据http中,当前用户的id做为标识,对该用户的操作频率做限制,代码如下:

public class RequestsPerUserLimitFilter extends ZuulFilter implements InitializingBean, ApplicationContextAware {

    private ApplicationContext applicationContext;

    private AntPathMatcher antPathMatcher;
    /**
     * 限流配置服务
     */
    private List<RequestsPerUserLimitConfig> configs;

    public RequestsPerUserLimitFilter() {
        this.antPathMatcher = new AntPathMatcher();
    }

    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return 13;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {

        RequestContext ctx = RequestContext.getCurrentContext();
        try {
            if (CollectionUtils.isEmpty(configs)) {
                log.info("no requests per user limit configs!");
                return null;
            }
            HttpServletRequest request = ctx.getRequest();
            for (RequestsPerUserLimitConfig config : configs) {

                String urlPattern, limitMethod;
                Duration timeout;
                if (StringUtils.isEmpty(urlPattern = config.urlPattern)
                        || StringUtils.isEmpty(limitMethod = config.limitMethod)
                        || Objects.isNull(timeout = config.timeout)) {

                    log.info("invalid config {} when run!", JSON.toJSONString(config));
                    continue;
                }
                // 路径与请求方法匹配
                if (antPathMatcher.match(urlPattern, RequestUtils.getRequestUriIfRemovePreServiceId(request))
                        && limitMethod.equalsIgnoreCase(request.getMethod())) {
                    // 用户id+(请求方法+请求路径).hash
                    String key = RedisConfig.REDIS_PRE + getCurrentUserId(request) + ":" + (request.getMethod() + request.getRequestURI()).hashCode();
                    try {
                        // 如果不存在,则设置成功,并且设置了失效时间
                        RedisTemplate<String, Object> redisTemplate = this.applicationContext.getBean(RedisTemplate.class);
                        boolean success = redisTemplate.opsForValue().setIfAbsent(key, Thread.currentThread().getId(), timeout);
                        if (!success) {

                            log.warn("limit request per user!url {},method {}", request.getRequestURI(), request.getMethod());
                            // 不走路由,RibbonRoutingFilter shouldFilter 判断条件之一
                            ctx.setSendZuulResponse(false);
                            ctx.setResponseStatusCode(HttpStatus.TOO_MANY_REQUESTS.value());
                            ctx.setResponseBody(HttpStatus.TOO_MANY_REQUESTS.toString());
                        }
                    } catch (Exception e) {
                        log.warn("limit request per user error!", e);
                    }
                }
            }
        } catch (Exception e) {

            String errorMsg = "RequestsPerUserLimitFilter error when run!";
            log.error(errorMsg, e);
            WebUtils.responseOutJson(ctx.getResponse(), JSON.toJSONString(com.kuqi.mall.common.response.Response.builder().code(500).message(errorMsg).build()));
        }
        return null;
    }

    /**
     * 获取用户id
     */
    private Long getCurrentUserId(HttpServletRequest request) {

        String accessToken = RequestUtils.extractToken(request);
        UserClient userClient = applicationContext.getBean(UserClient.class);
        if (StringUtils.isBlank(accessToken) || Objects.isNull(userClient)) {
            return null;
        }
        User user = userClient.get(accessToken);
        if (Objects.isNull(user)) {
            return null;
        }
        return user.getId();
    }

    @Override
    public void afterPropertiesSet() throws Exception {

        RequestsLimitService requestsLimitService = this.applicationContext.getBean(RequestsLimitService.class);
        if (Objects.nonNull(requestsLimitService)) {
            this.configs = requestsLimitService.findRequestsPerUserLimitList();
        }
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    /**
     * 用户限流配置
     */
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class RequestsPerUserLimitConfig implements Serializable {

        private static final long serialVersionUID = 8958979944866916321L;

        /**
         * 拦截url的pattern
         */
        private String urlPattern;
        /**
         * 拦截方法
         */
        private String limitMethod;
        /**
         * 限流失效时间
         */
        private Duration timeout;
    }
}

 

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由半码博客整理,本文链接:https://www.bmabk.com/index.php/post/13627.html

(0)
小半的头像小半

相关推荐

半码博客——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!