前言:本课程是在慕课网上学习Spring Cloud微服务实战 第9章 Zuul综合使用 时所做的笔记,供本人复习之用.
代码地址 https://github.com/springcloud-demo
目录
第一章 Pre与Post过滤器
1.1 实现Pre过滤器
1.2 实现Post过滤器
第二章 限流
2.1 代码实现
第三章 Zuul实现权限控制例子思路概要
第四章 添加用户服务
4.1 用户服务简介
4.2 用户服务的实现
第五章 订单状态改变功能实现
第六章 Zuul鉴权
6.1 代码优化
6.2 网关通过数据库鉴权
6.3 总结
第七章 跨域
下面是整个项目的架构图
可以看到所有的请求都要经过Zuul,然后才会到Service A,B,C.那么我们现在要对请求做一个权限的校验,假如没有A,B,C,我们都要对权限校验一次,比较麻烦,权限校验可以放在Zuul这里统一的进行校验,
写一个Filter实现ZuulFilter,重写四个方法.
第一个方法代表这个过滤器在哪一部分的过滤器,如果是在pre部分的过滤器,返回PRE_TYPE,如果是post部分的过滤器,返回PRE_POST,更多的类型可以见常量类FilterConstants.
第二个方法代表这个过滤器的优先级,数字越小,优先级越高,这里我们选择我们的过滤器作用在PRE_DECORATION_FILTER的前面,所以找到其所对应的顺序数字减一.更多的过滤器级别数字可以见FilterConstants.
第三个方法统一返回true.
第四个方法实现我们的过滤器的逻辑,通过RequestContext获取request请求,如果request的请求中有token字符串就放过,没有就设置请求失败,返回HttpStatus设置的401错误.
@Component
public class TokenFilter extends ZuulFilter {
//代表是pre,还是post
@Override
public String filterType() {
return PRE_TYPE;
}
@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER-1;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletRequest request = requestContext.getRequest();
String token = request.getParameter("token");
if(StringUtils.isEmpty(token)){
requestContext.setSendZuulResponse(false);
requestContext.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}
return null;
}
}
结果:
请求 http://localhost:9000/myProduct/product/list?token=123 ,成功
请求 http://localhost:9000/myProduct/product/list ,失败,显示401错误.
大体思路同上.
@Component
public class AddResponseHeaderFilter extends ZuulFilter {
@Override
public String filterType() {
return POST_TYPE;
}
@Override
public int filterOrder() {
return SEND_RESPONSE_FILTER_ORDER -1 ;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletResponse response = requestContext.getResponse();
response.setHeader("X-Foo", UUID.randomUUID().toString());
return null;
}
}
结果:
由于Zuul充当的是API网关的角色,每个请求都会经过它,所以很适合在它上面对API做限流保护,防止网络攻击.比如某个API是发短信的,我们就要限制API的请求速率.在一定程度上抵御短信轰炸攻击,降低损失.
限流是放在前置过滤器中去做的,更现实一点说,是在请求被转发之前调用.如果前置过滤器有多个操作,限流应该放到最前面的地方,比如Zuul的前置过滤器里有限流,也有鉴权,那么限流应该早于鉴权,
限流的方案很多,有一种方案叫做令牌桶限流,会以一定的速率向桶中放入令牌,如果放满了就会丢弃掉,web请求过来会从令牌桶中获取到令牌,拿到令牌后才可以继续往下走,如果令牌都拿不到就会被拒绝.
设置每秒钟向令牌桶里放入100个令牌,因为是限流,所以应该在所有过滤器的最前面,这通过设置Order为最小的Order数字减1,来设置优先级最高.最后过滤时的逻辑是,尝试获得令牌,如果获取不到令牌抛出异常.
@Component
public class RateLimitFilter extends ZuulFilter {
private static final RateLimiter RATE_LIMITER = RateLimiter.create(100);
@Override
public String filterType() {
return PRE_TYPE;
}
@Override
public int filterOrder() {
return SERVLET_DETECTION_FILTER_ORDER-1;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
if(!RATE_LIMITER.tryAcquire()){
throw new RateLimitException();
}
return null;
}
}
public class RateLimitException extends RuntimeException{
}
我们要实现角色的划分,最终让不同的角色访问不同的url.
我们写三个功能作为例子.
创建订单功能: /order/create 只能买家角色访问
改变订单状态功能: /order/finish 只能卖家角色访问
查看商品 /poduct/list 都可以访问
我们将分几步去实现这些功能
1.创建user服务,在user中完成买家与卖家的登陆
2.在订单服务中完成/order/finish的功能(/order/create功能在前面已经创建过了)
3.在Zuul中区别是买家登陆还是卖家登陆
4.最后将三者结合,用user服务进行登陆,用户访问订单服务时由Zuul进行鉴权,是买家才能访问 /order/create,是卖家才能访问 /order/finish.
买家登陆接口
GET /login/buyer 参数 openid:abc
返回:
1. Cookie中设置 openid=abc
2. 返回的提示{ code:0,msg"成功",data:null }
之所以带的参数是openid而不是用户名密码是因为用的是微信登陆,当用户扫码登陆后,微信会返回openid.
卖家登陆接口:
GET /login/seller 参数 openid:xyz
返回:
1. Cookie中设置 token=UUID
2.redis设置key=UUID, value=xyz
3. 返回的提示{ code:0,msg"成功",data:null }
经过上面这样的登陆设置,我们用openid来作为用户登陆请求的凭证,根据Cookie中的是token还是openid来分辨用户是否已登陆以及用户的权限.
用户表的结构:
买家登陆:
@GetMapping("/buyer")
public ResultVO buyer(@RequestParam("openid") String openid, HttpServletResponse response){
//1.通过openid去数据库中查找对应用户
UserInfo userInfo = userService.findByOpenid(openid);
if(userInfo==null){
return ResultVOUtil.error(ResultEnum.LOGIN_ERROR.getCode(),ResultEnum.LOGIN_ERROR.getMessage());
}
//2.判断用户的角色是不是买家
if(RoleEnum.BUYER.getCode()!=userInfo.getRole()){
return ResultVOUtil.error(ResultEnum.ROLE_ERROR.getCode(),ResultEnum.ROLE_ERROR.getMessage());
}
//3.如果是买家就在cookie里设置openid=xxx,然后返回
CookieUtil.set(response, CookieConstant.OPENID,openid,CookieConstant.expire);
return ResultVOUtil.success();
}
结果:
卖家登陆
@GetMapping("/seller")
public ResultVO seller(@RequestParam("openid") String openid, HttpServletResponse response, HttpServletRequest request){
//判断是否已登陆,为避免每次登陆都生成不同的UUID.
Cookie cookie = CookieUtil.get(request, CookieConstant.TOKEN);
if(cookie!=null&&
//如果从cookie中取出的token在redis中能找到,说明已登陆,直接返回登陆成功. !StringUtils.isEmpty(stringRedisTemplate.opsForValue().get(String.format(RedisConstant.Token_TEMPLATE,cookie.getValue())))){
return ResultVOUtil.success();
}
//1.通过openid去数据库中查找对应用户
UserInfo userInfo = userService.findByOpenid(openid);
if(userInfo==null){
return ResultVOUtil.error(ResultEnum.LOGIN_ERROR.getCode(),ResultEnum.LOGIN_ERROR.getMessage());
}
//2.判断用户的角色是不是卖家
if(RoleEnum.SELLER.getCode()!=userInfo.getRole()){
return ResultVOUtil.error(ResultEnum.ROLE_ERROR.getCode(),ResultEnum.ROLE_ERROR.getMessage());
}
//3.如果是卖家,redis设置key=UUID,value=xxx
String token = UUID.randomUUID().toString();
Integer expire = CookieConstant.expire;
stringRedisTemplate.opsForValue().set(String.format(RedisConstant.Token_TEMPLATE,token),
openid,
expire,
TimeUnit.SECONDS);
//且设置cookie token=UUID
CookieUtil.set(response, CookieConstant.TOKEN,token,CookieConstant.expire);
return ResultVOUtil.success();
}
结果:
controller:
@PostMapping("/finish")
public ResultVO finish(@RequestParam("orderId") String orderId){
return ResultVOUtil.success(orderService.finish(orderId));
}
service:
@Transactional
@Override
public OrderDTO finish(String orderId) {
//1.根据订单id找到订单
Optional orderMasterOptional = orderMasterRepository.findById(orderId);
if(!orderMasterOptional.isPresent()){
throw new OrderException(ResultEnum.ORDER_NOT_EXIST.getCode(),ResultEnum.ORDER_NOT_EXIST.getMessage());
}
//2.判断订单状态是否为未完结
OrderMaster orderMaster = orderMasterOptional.get();
if(OrderStatusEnum.NEW.getCode()!=orderMaster.getOrderStatus()){
throw new OrderException(ResultEnum.ORDER_STATUS_ERROR.getCode(),ResultEnum.ORDER_STATUS_ERROR.getMessage());
}
//3.修改订单状态为已完结
orderMaster.setOrderStatus(OrderStatusEnum.FINISHED.getCode());
orderMasterRepository.save(orderMaster);
//封装订单详情
List orderDetailList = orderDetailRepository.findByOrderId(orderId);
if(CollectionUtils.isEmpty(orderDetailList)){
throw new OrderException(ResultEnum.ORDER_DETAIL_NOT_EXIST.getCode(),ResultEnum.ORDER_DETAIL_NOT_EXIST.getMessage());
}
OrderDTO orderDTO = new OrderDTO();
//OrderDTO为订单orderMaster的部分信息,避免泄漏关键数据
BeanUtils.copyProperties(orderMaster,orderDTO);
orderDTO.setOrderDetailList(orderDetailList);
return orderDTO;
}
运行结果,将orderStatus的状态由0变为1.
访问订单服务时都要经过Zuul,所以我们将权限校验放在Zuul,要让Zuul区分出当前是买家正在登陆,还是卖家正在登陆.以便决定最终能不能访问对应的url.
根据第四章说明,如果是买家登陆cookie里有openid.
如果是卖家登陆,cookie有token,并且对应的redis中的值.
这里要在application.properties注意要去除敏感头,不然cookie不能返回.
我们通过在Zuul中新增加一个过滤器的方式来实现Zuul的鉴权
@Slf4j
@Component
public class AuthFilter extends ZuulFilter {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public String filterType() {
return PRE_TYPE;
}
@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER-1;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletRequest request = requestContext.getRequest();
//用户访问如下链接,必须是买家才可以访问.
if("/order/order/create".equals(request.getRequestURI())){
Cookie cookie = CookieUtil.get(request, "openid");
if(cookie ==null||StringUtils.isEmpty(cookie.getValue())){
requestContext.setSendZuulResponse(false);
requestContext.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}
}
//用户访问如下链接,必须是卖家才可以访问.
if("/order/order/finish".equals(request.getRequestURI())){
Cookie cookie = CookieUtil.get(request, "token");
//没有cookie或者cookie没有值或cookie里的值不能对应
//redis中的key则设置请求失败,返回401
if(cookie == null
|| StringUtils.isEmpty(cookie.getValue())
|| StringUtils.isEmpty(stringRedisTemplate.opsForValue().get(String.format(RedisConstant.Token_TEMPLATE,cookie.getValue())))){
requestContext.setSendZuulResponse(false);
requestContext.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}
}
return null;
}
}
开启所有服务
结果,用9000是经过Zuul的,
cookie中不带token
cookie中带token,表明是卖家.
上面的鉴权虽然实现了功能,但是相当不好维护.判断的权限很多,现在我们是耦合进行判断的,即create与finish都在一个filter里,如果我们要删除一个权限判断如create,我们要找到代码位置将其删除,如果代码多了不好维护.可以进行如下优化.
对于每一种权限校验,都单独建立一个过滤器.
买家权限校验写在AuthBuyerFilter过滤器中,是否要做校验写在shouldFilter中.
卖家权限校验写在AuthSellerFilter过滤器中,是否要做校验写在shouldFilter中.
买家权限校验核心代码:
@Override
public boolean shouldFilter() {
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletRequest request = requestContext.getRequest();
//对于买家权限这里是一个地址,如果是多个地址就向这里面去加就行了.
// /order/create 只能买家访问(cookie里有openid)
if("/order/order/create".equals(request.getRequestURI())) return true;
else return false;
}
@Override
public Object run() throws ZuulException {
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletRequest request = requestContext.getRequest();
Cookie cookie = CookieUtil.get(request, "openid");
if(cookie ==null||StringUtils.isEmpty(cookie.getValue())){
requestContext.setSendZuulResponse(false);
requestContext.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}
return null;
}
卖家权限校验核心代码:
@Override
public boolean shouldFilter() {
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletRequest request = requestContext.getRequest();
if("/order/order/finish".equals(request.getRequestURI())){
return true;
}
return false;
}
@Override
public Object run() throws ZuulException {
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletRequest request = requestContext.getRequest();
// /order/finish 只能卖家访问(cookie里有token,并且对应的redis中有值)
Cookie cookie = CookieUtil.get(request, "token");
if(cookie == null
|| StringUtils.isEmpty(cookie.getValue())
|| StringUtils.isEmpty(stringRedisTemplate.opsForValue().get(String.format(RedisConstant.Token_TEMPLATE,cookie.getValue())))){
requestContext.setSendZuulResponse(false);
requestContext.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}
return null;
}
在这里我们用openid与token的方式鉴别买家与卖家,大多数情况下,我们会把这类的信息存储到数据库中去.
是不是应该在run方法中用去连接数据库来判断用户的身份呢?这样做不大好,因为api-gateway主要是做一个网关,而让它直接去连user数据库是不合适的,要注意边界.
我们可以去调用user的服务.但是如果每次请求鉴权都去调用user服务,user服务去调数据库的话,还是对数据库压力挺大的.
个人建议还是api-gateway直接去调用redis里的信息就可以直接判断用户的权限,当然redis里的信息怎么过来呢?可以像之前说的异步扣库存的方式,用户信息一变动可以发一个消息出来,网关这边监听消息,把它记录到redis中这样.
1. 前面几节我们添加了用户服务,通过Zuul完成了对不同角色url的控制,微服务架构下,多个微服务都需要对访问进行鉴权,每个微服务都需要明白当前访问的用户及其权限,在Zuul的前置过滤器里实现相关逻辑是值得考虑的方案,同时在微服务中,多个服务的无状态化一般会考虑两种技术方案,一种是分布式session,一种是OAuth2.
我们采用的是分布式session方案.就是将关于用户认证的信息存储到共享储存中.且通常用用户会话作为key来实现简单的分布式哈希映射,当用户访问微服务时,用户数据可以从共享储存中获取,用户登陆状态是不透明的,同时也是高可用且扩展的解决方案.
另一种是OAuth2.0与Spring Security结合方案.
2. 我们在添加用户服务的时候,util之类的代码总是在复制,我们这里少了一个基础服务,如果公司是比较大型的项目进行改造,基础服务会比较了然的被拆出来,因为这部分代码已经有了所以比较好分辨.但是如果是一个从头开始开发的项目,不是很有把握,首先可以将微服务的公用组件放到公用模块上去.就比如现在的user,order,product服务的common模块中,有了积累之后就可以很自然的将代码剥离出来作为公共组件下层,成为公共服务.
3. SpringCloud体系内架构的所有微服务都是通过Zuul对外提供统一的访问入口,这个时候如果公司里有两套系统,一个是传统的项目,另外一套是微服务架构的项目,让这两套项目在线上同时运行,Zuul会非常关键.
前面提到我们的点餐项目是前后端分离的,也就是说前端是通过ajax发起请求,浏览器的ajax是有同源策略的,如果违反了同源策略就会产生跨域问题,Zuul作为api网关,在它上面处理跨域也是一种选择.
Zuul的跨域可以看成是Spring的一种跨域,Spring跨域常用的一种方法是在被调用的类或方法上增加@CrossOrigin注解.这种方式的缺点挺明显的,这种方式的作用域是在类或者方法上,我们这里光应用就很多,应用里的类或者方法就更多了.
也可以在nginx上来做
还可以在在Zuul里增加CorsFilter过滤器.我们下面介绍这种方法.
在Zuul中新建CorsConfig类,C - Cross O - Origin R - Resource S - sharing
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter(){
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
final CorsConfiguration config = new CorsConfiguration();
//支持cookie跨域
config.setAllowCredentials(true);
//放原始域(http:www.a.com) *是全部允许
config.setAllowedOrigins(Arrays.asList("*"));
//放允许的头
config.setAllowedHeaders(Arrays.asList("*"));
//放允许哪些方法
config.setAllowedMethods(Arrays.asList("*"));
//设置缓存时间,在这个时间段里,对于相同的跨域请求不再进行检查.
config.setMaxAge(300l);
//将跨域的配置注册到source上面去
source.registerCorsConfiguration("/**",config);
return new CorsFilter(source) ;
}
}
如果对跨域感兴趣,推荐一门慕课网上免费的跨域课程,ajax跨域完全讲解(不是我推荐的,是讲课的老师推荐的,打算以后看看,就在此记录了一下)