网关

api-gateway

api-gateway是一款轻量级、高性能、易扩展的基于zuul的网关产品,提供API的统一管理服务、涵盖API发布、管理、运维的全生命周期管理。对内辅助用户简单、快速、低成本、低风险的实现微服务聚合、前后端分离、系统集成等功能;对外面向合作伙伴、开发者开放服务。通过使用API-Gateway,我们能快速帮助用户实现传统ESB面临的主要场景,又能满足新型业务场景(移动应用等)所需的高性能、安全、可靠等要求。

通用网关设计

软负载ZUUL

网关活动图

api-gateway在项目中的位置

api gateway作用

  • 简化客户端调用复杂度
    在微服务架构模式下后端服务的实例数一般是动态的,对于客户端而言,很难发现动态改变的服务实例的访问地址信息。因此在基于微服务的项目中为了简化前端的调用逻辑,通常会引入API Gateway作为轻量级网关,同时API Gateway中也会实现相关的认证逻辑从而简化内部服务之间相互调用的复杂度。
  • 数据裁剪以及聚合
    通常而言不同的客户端在显示时对于数据的需求是不一致的,比如手机端或者Web端又或者在低延迟的网络环境或者高延迟的网络环境。因此为了优化客户端的使用体验,API Gateway可以对通用性的响应数据进行裁剪以适应不同客户端的使用需求,同时还可以将多个API调用逻辑进行聚合,从而减少客户端的请求数,优化客户端用户体验。
  • 多渠道支持
    当然我们还可以针对不同的渠道和客户端提供不同的API Gateway,对于该模式的使用由另外一个大家熟知的方式叫Backend for front-end,在Backend for front-end模式当中,我们可以针对不同的客户端分别创建其BFF。
  • 遗留系统的微服务改造
    对于遗留系统而言进行微服务改造通常是由于原有的系统存在或多或少的问题,比如技术债务,代码质量,可维护性,可扩展性等等。API Gateway的模式同样适用于这一类遗留系统的改造,通过微服务化的改造逐步实现对原有系统中的问题的修复,从而提升对于原有业务相应力的提升。通过引入抽象层,逐步使用新的实现替换旧的实现。

    在Spring Cloud体系中,Spring Cloud Zuul就是提供负载均衡,反向代理,权限认证的一个API Gateway。

api-gateway代码分析

开启ZUUL

Zuul提供的功能

  • 认证鉴权-可以识别访问资源的每一个请求,拒绝不满足的请求
  • 监控埋点,跟踪有意义的数据并统计,以便生成有意义的生产视图
  • 熔断限流,为每一个请求分配容量,并丢弃超过限制的请求
  • 抗压设计,线程池隔离增大并发能力

网关的2层超时调优

hystrix ribbon活动图

hystrix ribbon配置

#设置最大容错超时时间
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds: 90000
#设置最大超时时间
ribbon:  
  eager-load:
    enabled: true
  ServerListRefreshInterval: 10  #刷新服务列表源的间隔时间
  httpclient:
    enabled: false
  okhttp:
    enabled: true  
  ReadTimeout: 90000  
  ConnectTimeout: 90000 
  OkToRetryOnAllOperations: true
  MaxAutoRetries: 1
  MaxAutoRetriesNextServer: 1

源码分析

zuul内部代码

类图

参考:https://www.jianshu.com/p/295e51bc1518

zuul基于eureka的服务发现路由

路由注册相关逻辑

org.springframework.cloud.netflix.zuul.filters.discovery.DiscoveryClientRouteLocator

启动时配置mapping等信息

zuul转发逻辑

以访问http://127.0.0.1:9200/api-user/users-anon/login?username=admin为例

  • 通过/api-user/users-anon/login查映射表
  • 找到ZuulRoute映射关系
  • 执行pre过滤器org.springframework.cloud.netflix.zuul.filters.pre.PreDecorationFilter处理是否增加请求头等信息
  • 执行route过滤器
  • org.springframework.cloud.netflix.zuul.filters.route.RibbonRoutingFilter

总结

  • pre:这种过滤器在请求被转发之前调用,一般用来实现身份验证等
  • routing:这种路由是用来路由到不同的后端服务的,底层可以使用httpclient或者ribbon请求微服务
  • post:当请求转发到微服务以后,会调用当前类型的过滤器。通常用来为响应天啊及标椎的HTTP Header、收集统计信息,等
  • error:当发生错误是执行的过滤器

网关自定义过滤器


  • brave.servlet.TracingFilter :生成traceId
  • com.open.capacity.common.filter.TraceContextFilter:传递traceId
  • com.open.capacity.client.filter.AccessFilter:传递token
  • com.open.capacity.client.filter.RequestStatFilter::传递traceId
  • com.open.capacity.client.filter.ResponseStatFilter:响应头增加traceId

基于阿波罗配置中心的动态路由

参考代码:https://gitee.com/owenwangwen/config-center/tree/master/apollo-gateway

阿波罗官方已吸收此案例在github可下载

https://github.com/ctripcorp/apollo-use-cases/tree/master/spring-cloud-zuul/src/main/java/com/ctrip/framework/apollo/use/cases/spring/cloud/zuul

  • 创建项目
  • 创建配置
  • 项目绑定配置

api-gateway 构建资源服务器

<!-- 资源服务器 -->
  		<dependency>
  			<groupId>com.open.capacity</groupId>
  			<artifactId>uaa-client-spring-boot-starter</artifactId>
  		</dependency>

uaa-client-spring-boot-starter源码分析

网关认证处理流程图

网关白名单

security:
  oauth2:
    ignored:  /test163/** , /api-auth/** , /doc.html ,/test111 ,/api-user/users-anon/login, /api-user/users/save,    /user-center/users-anon/login,/document.html,**/v2/api-docs,/oauth/** ,/login.html ,/user/login,/**/**.css ,/**/**.js   ,/getVersion
    token:
      store:
        type: redis

网关统一异常

认证处理核心代码

public Authentication authenticate(Authentication authentication) throws AuthenticationException {

		if (authentication == null) {
			throw new InvalidTokenException("Invalid token (token not found)");
		}
		String token = (String) authentication.getPrincipal();
		OAuth2Authentication auth = tokenServices.loadAuthentication(token);
		if (auth == null) {
			throw new InvalidTokenException("Invalid token: " + token);
		}

		Collection<String> resourceIds = auth.getOAuth2Request().getResourceIds();
		if (resourceId != null && resourceIds != null && !resourceIds.isEmpty() && !resourceIds.contains(resourceId)) {
			throw new OAuth2AccessDeniedException("Invalid token does not contain resource id (" + resourceId + ")");
		}

		checkClientDetails(auth);

		if (authentication.getDetails() instanceof OAuth2AuthenticationDetails) {
			OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
			// Guard against a cached copy of the same details
			if (!details.equals(auth.getDetails())) {
				// Preserve the authentication details from the one loaded by token services
				details.setDecodedDetails(auth.getDetails());
			}
		}
		auth.setDetails(authentication.getDetails());
		auth.setAuthenticated(true);
		return auth;

授权流程

启用授权

@Bean
    public OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler(ApplicationContext applicationContext) {
        OAuth2WebSecurityExpressionHandler expressionHandler = new OAuth2WebSecurityExpressionHandler();
        expressionHandler.setApplicationContext(applicationContext);
        return expressionHandler;
    }

网关认证授权总结

  • 访问者(Accessor)需要访问某个资源(Resource)是这个场景最原始的需求,但并不是谁都可以访问资源,也不是任何资源都允许任何人来访问,所以中间我们要加入一些检查和防护
  • 在访问资源的所经之路上,可能遇到细菌,病毒,不管怎么样,对于要防护的资源来说最好的方法就是设关卡点,对于上图的FilterSecurityInvation,MethodIncation,Jointpoint,这些在spring security oauth中统称SecuredObjects
  • 我们知道在哪里设置关卡点最合适,下一步就是设置关卡,对应FileSecurityInterceptor,MethodSecurityInterceptor,AspectSecurityInterceptor,
    这些关卡统一的抽象类是AbstractSecurityInterceptor
  • 有关卡点,关卡了以后,到底谁该拦截谁不应该呢,spring security oauth中由 AccessDecisionManager控制
  • 最后一个问题,这个谁怎么定义,我们总得知道当前这个访问者是谁才能告诉AccessDecisionManager拦截还是放行,在spring security oauth框架中AuthenticationManager将解决访问者身份认证问题,只有确定你在册了,才可以给授权访问。AuthenticationManager,AccessDecisionManager,AbstractSecurityInterceptor属于spring security框架的基础铁三角。
  • 有了以上骨架,真正执行防护任务的其实是SecurityFilterChain中定于的一系列Filter,其中ExceptionTranslationFilter,它负责接待或者送客,如果访问者来访,对方没有报上名来,那么,它就会让访客去登记认证(找AuthenticationManager做认证),如果对方报上名了,但认证失败,那么请重新认证送客,送客的方式是抛出相应的Exception,所以名字叫做ExceptionTranslationFilter。
  • 最后,这个filter序列中可能不满足我们的需求,比如增加验证码,所以我们需要在其中穿插自己的Filter实现类,为定制和扩展Spring Security Oauth的防护体系。
  • spring security内置的filter序列
  • 执行过滤链

网关api权限设计


相同用户,不同应用的权限隔离
客户端模式 :
客户端A 申请的token ,可以访问/api-user/menu/current ,
客户端B 申请的token,不让访问/api-user/menu/current
密码模式:
客户端模式 :
客户端A admin用户 申请的token ,可以访问/api-user/menu/current ,
客户端B admin用户 申请的token,不让访问/api-user/menu/current

参考issue:https://gitee.com/owenwangwen/open-capacity-platform/issues/IRG23

网关引依赖

网关是否开启基于应用隔离,代码注释了,只是基于token的合法性校验,按建议开启是否启用api接口服务权限
OpenAuthorizeConfigManager

游乐场买了通票,有些地方可以随便玩,有些地方另外
单独校验买票
config.anyRequest().authenticated() ;
//这种通票,token校验正确访问
config.anyRequest().access("@rbacService.hasPermission(request, authentication)"); //这种另外
单独校验,适用于网关对api权限校验


通过clientID隔离服务权限

通过应用分配服务权限

网关hystrix-dashboard

api-gateway 应用访问次数控制

oauth_client_details 增加

字段备注
if_limit是否需要控制访问次数
limit_count访问阀值

auth-server 启动

redis存储结构

加载oauth_client_details 到redis

应用访问次数控制过滤器

/**
 * Created by owen on 2017/9/10. 根据应用 url 限流 oauth_client_details if_limit 限流开关
 * limit_count 阈值
 */
@Component
public class RateLimitFilter extends ZuulFilter {

	private static Logger logger = LoggerFactory.getLogger(RateLimitFilter.class);

	private ThreadLocal<Result> error_info = new ThreadLocal<Result>();
	@Autowired
	private RedisLimiterUtils redisLimiterUtils;
	@Autowired
	private ObjectMapper objectMapper;

	@Resource
	SysClientServiceImpl sysClientServiceImpl;

	@Override
	public String filterType() {
		return "pre";
	}

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

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

	@Override
	public Object run() {

		try {
			RequestContext ctx = RequestContext.getCurrentContext();
			HttpServletRequest request = ctx.getRequest();
			if (!checkLimit(request)) {

				logger.error("too many requests!");
				error_info.set(Result.failedWith(null, 429, "too many requests!"));

				serverResponse(ctx, 429);
				return null;

			}

		} catch (Exception e) {
			e.printStackTrace();
		}
		return null;
	}

	/***
	 * 统一禁用输出
	 * 
	 * @param ctx
	 * @param ret_message
	 *            输出消息
	 * @param http_code
	 *            返回码
	 */
	public void serverResponse(RequestContext ctx, int http_code) {


		try {
			ctx.setSendZuulResponse(false);
			outputChineseByOutputStream(ctx.getResponse(), error_info);
			ctx.setResponseStatusCode(http_code);
		} catch (IOException e) {
			StackTraceElement stackTraceElement= e.getStackTrace()[0];
			logger.error("serverResponse:" + "---|Exception:" +stackTraceElement.getLineNumber()+"----"+ e.getMessage());
		
		}

	}

	/**
	 * 使用OutputStream流输出中文
	 * 
	 * @param request
	 * @param response
	 * @throws IOException
	 */
	public void outputChineseByOutputStream(HttpServletResponse response, ThreadLocal<Result> data) throws IOException {
		/**
		 * 使用OutputStream输出中文注意问题: 在服务器端,数据是以哪个码表输出的,那么就要控制客户端浏览器以相应的码表打开,
		 * 比如:outputStream.write("中国".getBytes("UTF-8"));//使用OutputStream流向客户端浏览器输出中文,以UTF-8的编码进行输出
		 * 此时就要控制客户端浏览器以UTF-8的编码打开,否则显示的时候就会出现中文乱码,那么在服务器端如何控制客户端浏览器以以UTF-8的编码显示数据呢?
		 * 可以通过设置响应头控制浏览器的行为,例如: response.setHeader("content-type",
		 * "text/html;charset=UTF-8");//通过设置响应头控制浏览器以UTF-8的编码显示数据
		 */

		OutputStream outputStream = response.getOutputStream();// 获取OutputStream输出流
		response.setHeader("content-type", "application/json;charset=UTF-8");// 通过设置响应头控制浏览器以UTF-8的编码显示数据,如果不加这句话,那么浏览器显示的将是乱码
		/**
		 * data.getBytes()是一个将字符转换成字节数组的过程,这个过程中一定会去查码表,
		 * 如果是中文的操作系统环境,默认就是查找查GB2312的码表, 将字符转换成字节数组的过程就是将中文字符转换成GB2312的码表上对应的数字
		 * 比如: "中"在GB2312的码表上对应的数字是98 "国"在GB2312的码表上对应的数字是99
		 */
		/**
		 * getBytes()方法如果不带参数,那么就会根据操作系统的语言环境来选择转换码表,如果是中文操作系统,那么就使用GB2312的码表
		 */

		String msg = objectMapper.writeValueAsString(data.get());

		byte[] dataByteArr = msg.getBytes("UTF-8");// 将字符转换成字节数组,指定以UTF-8编码进行转换
		outputStream.write(dataByteArr);// 使用OutputStream流向客户端输出字节数组
	}

	public boolean checkLimit(HttpServletRequest request) {

		// 解决zuul token传递问题
		Authentication user = SecurityContextHolder.getContext().getAuthentication();

		if (user != null) {

			if (user instanceof OAuth2Authentication) {

				try {
					OAuth2Authentication athentication = (OAuth2Authentication) user;
					OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) athentication.getDetails();

					String clientId = athentication.getOAuth2Request().getClientId();

					Map client = sysClientServiceImpl.getClient(clientId);
					
					if(client!=null){
						String flag = String.valueOf(client.get("if_limit") ) ;
						
						if("1".equals(flag)){
							String accessLimitCount =  String.valueOf(client.get("limit_count") );
							if (!accessLimitCount.isEmpty()) {
								Result result = redisLimiterUtils.rateLimitOfDay(clientId, request.getRequestURI(),
										Long.parseLong(accessLimitCount));
								if (-1 == result.getResp_code()) {
									logger.error("token:" + details.getTokenValue() + result.getResp_msg());
									// ((ResultMsg)
									// this.error_info.get()).setMsg("clientid:" +
									// client_id + ":token:" + accessToken + ":" +
									// result.getMsg());
									// ((ResultMsg) this.error_info.get()).setCode(401);
									return false;
								}
							}
						}
					}
				} catch (Exception e) {
					StackTraceElement stackTraceElement= e.getStackTrace()[0];
					logger.error("checkLimit:" + "---|Exception:" +stackTraceElement.getLineNumber()+"----"+ e.getMessage());
				}
				
				 

			}

		}
		return true;
	}
}

RedisLimiterUtils核心类


@Component
public class RedisLimiterUtils {
    public static final String API_WEB_TIME_KEY = "time_key:";
    public static final String API_WEB_COUNTER_KEY = "counter_key:";
    private static final String EXCEEDS_LIMIT = "规定的时间内超出了访问的限制!";
    private static Logger logger = LoggerFactory.getLogger(RedisLimiterUtils.class);
    @Resource
    RedisTemplate<Object, Object> redisTemplate;
    @Resource(name = "stringRedisTemplate")
    ValueOperations<String, String> ops;
    @Resource(name = "redisTemplate")
    ValueOperations<Object, Object> objOps;

    @Resource
    RedisUtil redisUtil;

    public Result IpRateLimiter(String ip, int limit, int timeout) {
        String identifier = UUID.randomUUID().toString();
        String time_key = "time_key:ip:" + ip;
        String counter_key = "counter_key:ip:" + ip;

        if (!redisUtil.hasKey(time_key) || redisUtil.getExpire(time_key) <= 0) {
            redisUtil.set(time_key, identifier, timeout);
            redisUtil.set(counter_key, 0);
        }
        if (redisUtil.hasKey(time_key) && redisUtil.incr(counter_key, 1) > limit) {
            logger.info(EXCEEDS_LIMIT);
            return Result.failedWith(null, -1, EXCEEDS_LIMIT);
        }
        return Result.succeedWith(null, 0,  "调用次数:" + this.ops.get(counter_key) );
    }


    public Result clientRateLimiter(String clientid, int limit, int timeout) {
        String identifier = UUID.randomUUID().toString();
        String time_key = "time_key:clientid:" + clientid;
        String counter_key = "counter_key:clientid:" + clientid;
        if (!redisUtil.hasKey(time_key) || redisUtil.getExpire(time_key) <= 0) {
            redisUtil.set(time_key, identifier, timeout);
            redisUtil.set(counter_key, 0);
        }
        if (redisUtil.hasKey(time_key) && redisUtil.incr(counter_key, 1) > limit) {
            logger.info(EXCEEDS_LIMIT);
            return Result.failedWith(null, -1, EXCEEDS_LIMIT);
        }
        return Result.succeedWith(null, 0,  "调用次数:" + this.ops.get(counter_key) );
    }


    public Result urlRateLimiter(String path, int limit, int timeout) {
        String identifier = UUID.randomUUID().toString();
        String time_key = "time_key:path:" + path;
        String counter_key = "counter_key:path:" + path;
        if (!redisUtil.hasKey(time_key) || redisUtil.getExpire(time_key) <= 0) {
            redisUtil.set(time_key, identifier, timeout);
            redisUtil.set(counter_key, 0);
        }
        if (redisUtil.hasKey(time_key) && redisUtil.incr(counter_key, 1) > limit) {
            logger.info(EXCEEDS_LIMIT);
            return Result.failedWith(null, -1, EXCEEDS_LIMIT);
        }
        return Result.succeedWith(null, 0,  "调用次数:" + this.ops.get(counter_key) );
    }


    public Result clientPathRateLimiter(String clientid, String access_path, int limit, int timeout) {
        String identifier = UUID.randomUUID().toString();
        LocalDate today = LocalDate.now();
        String time_key = "time_key:clientid:" + clientid + ":path:" + access_path;
        String counter_key = "counter_key:clientid:" + clientid + ":path:" + access_path;

        if (!redisUtil.hasKey(time_key) || redisUtil.getExpire(time_key) <= 0) {
            redisUtil.set(time_key, identifier, timeout);
            redisUtil.set(counter_key, 0);
        }
        if (redisUtil.hasKey(time_key) && redisUtil.incr(counter_key, 1) > limit) {
            logger.info(EXCEEDS_LIMIT);
            return Result.failedWith(null, -1, EXCEEDS_LIMIT);
        }
        return Result.succeedWith(null, 0,  "调用次数:" + this.ops.get(counter_key) );
    }


    public Result rateLimitOfDay(String clientid, String access_path, long limit) {
        String identifier = UUID.randomUUID().toString();
        LocalDate today = LocalDate.now();
        String time_key = "time_key:date:" + today + ":clientid:" + clientid + ":path:" + access_path;
        String counter_key = "counter_key:date:" + today + ":clientid:" + clientid + ":path:" + access_path;

        if (!redisUtil.hasKey(time_key) || redisUtil.getExpire(time_key) <= 0) {
            //当天首次访问,初始化访问计数=0,有效期24h
            redisUtil.set(time_key, identifier, 24 * 60 * 60);
            redisUtil.set(counter_key, 0);
        }

        //累加访问次数, 超出配置的limit则返回错误
        if (redisUtil.incr(counter_key, 1) > limit) {
            logger.info("日内超出了访问的限制!");
            return Result.failedWith(null, -1, "日内超出了访问的限制!");
        }
        return Result.succeedWith(null, 0,  "调用总次数:" + this.ops.get(counter_key) );
    }


    public Result acquireRateLimiter(String clientid, String access_path, int limit, int timeout) {
        String identifier = UUID.randomUUID().toString();
        LocalDate today = LocalDate.now();
        String time_key = "time_key:date:" + today + ":clientid:" + clientid + ":path:" + access_path;
        String counter_key = "counter_key:date:" + today + ":clientid:" + clientid + ":path:" + access_path;

        if (!redisUtil.hasKey(time_key) || redisUtil.getExpire(time_key) <= 0) {
            redisUtil.set(time_key, identifier, timeout);
            redisUtil.set(counter_key, 0);
        }
        if (redisUtil.hasKey(time_key) && redisUtil.incr(counter_key, 1) > limit) {
            logger.info(EXCEEDS_LIMIT);
            return Result.failedWith(null, -1, EXCEEDS_LIMIT);
        }
        return Result.succeedWith(null, 0,  "调用次数:" + this.ops.get(counter_key) );
    }


    public void save(String tokenType, String Token, int timeout) {
        redisUtil.set(tokenType, Token, timeout);
    }


    public String getToken(String tokenType) {
        return redisUtil.get(tokenType).toString();
    }


    public void saveObject(String key, Object obj, long timeout) {
        redisUtil.set(key, obj, timeout);
    }


    public void saveObject(String key, Object obj) {
        redisUtil.set(key, obj);
    }


    public Object getObject(String key) {
        return redisUtil.get(key);
    }


    public void removeObject(String key) {
        redisUtil.del(key);
    }
}

生产软负载NGINX构建ZUUL集群

pom核心依赖

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐