SpringCloud微服务项目实战——系统实现篇

06 服务多不易管理如何破------服务注册与发现

经过上一篇系统性的介绍 Spring Cloud 及 Spring Cloud Alibaba 项目,相信你已经对这两个项目有个整体直观的感受,本篇开始正式进入本课程的第二部分,一起进入业务的开发阶段。

服务调用问题

在分析业务需求时,其中有个简单的功能点:会员可以开通月卡,开通月卡的同时,需要增加相应的积分。开通月卡功能在会员服务模块维护,但增加积分功能在积分服务模块维护,这就涉及到两个模块间的服务调用问题。

单实例情况:可以采用点对点的 HTTP 直接调用,采用 IP + Port + 接口的形式进行。也可以对外暴露 WebService 服务供外部模块调用,但 WebService 的形式显示比 HTTP的形式稍重一些,在实际的业务开发过程中,越来越的产品开发采用轻量级的 HTTP 协议进行数据交互。如果模块增多,将会形成蜘蛛网的形式,非常不利于开发维护。

多实例的情况:为应对服务的压力,采用多实例集群部署已成为简捷易用的解决方案。仅仅多实例部署后,直接面临一个问题,调用方如何知晓调用哪个实例,当实例运行失败后,如何转移到别的实例上去处理请求?如果采用了负载均衡,但往往是静态的,在服务不可用时,如果动态的更新负载均衡列表,保证调用者的正常调用呢?

面对以上两种情况,服务注册中心的需求迫在眉捷,将所有的服务统一的、动态的管理起来。

服务注册中心

服务注册中心作分布式服务框架的核心模块,可以看出要实现的功能是服务的注册、订阅,与之相应的功能是注销、通知这四个功能。

所有的服务都与注册中心发生连接,由注册中心统一配置管理,不再由实例自身直接调用。服务管理过程大致过程如下:

1、服务提供者启动时,将服务提供者的信息主动提交到服务注册中心进行服务注册。

2、服务调用者启动时,将服务提供者信息从注册中心下载到调用者本地,调用者从本地的服务提供者列表中,基于某种负载均衡策略选择一台服务实例发起远程调用,这是一个点到点调用的方式。

3、服务注册中心能够感知服务提供者某个实例下线,同时将该实例服务提供者信息从注册中心清除,并通知服务调用者集群中的每一个实例,告知服务调用者不再调用本实例,以免调用失败。

在开发过程中有很多服务注册中心的产品可供选择:

  • Consul
  • Zookeeper
  • Etcd
  • Eureka
  • Nacos

比如 Dubbo 开发时经常配合 Zookeeper 使用,Spring Cloud 开发时会配合 Eureka 使用,社区都提供相当成熟的实施方案,本案例中服务注册中心采用 Nacos 来进行实战,其它注册中心的使用,有兴趣的朋友可以课下稍做研究,基本应用还是比较简单的。

Nacos 应用

官网地址:https://nacos.io/en-us/,由阿里开源,一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台,已经作为 Spring Cloud Alibaba 的一个子项目,更好与 Spring Cloud 融合在一起。在关 Nacos 的详细信息可打开官网进行了解,这里不做过多讲述。下面直接进入我们的应用一节。

安装 Nacos

本次采用 1.1.4 版本:nacos-server-1.1.4.tar.gz,(最新版本中已集成权限管理功能)本次测试将采用单机版部署,下载后解压,直接使用对应命令启动。

tar -xvf nacos-server-$version.tar.gz cd nacos/bin sh startup.sh -m standalone (standalone代表着单机模式运行,非集群模式)

启动日志如下:

复制代码
appledeMacBook-Air:bin apple$ ./startup.sh -m standalone
/Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/bin/java  -Xms512m -Xmx512m -Xmn256m -Dnacos.standalone=true -Djava.ext.dirs=/Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/ext:/Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/lib/ext:/Users/apple/software/nacos/plugins/cmdb:/Users/apple/software/nacos/plugins/mysql -Xloggc:/Users/apple/software/nacos/logs/nacos_gc.log -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M -Dnacos.home=/Users/apple/software/nacos -Dloader.path=/Users/apple/software/nacos/plugins/health -jar /Users/apple/software/nacos/target/nacos-server.jar  --spring.config.location=classpath:/,classpath:/config/,file:./,file:./config/,file:/Users/apple/software/nacos/conf/ --logging.config=/Users/apple/software/nacos/conf/nacos-logback.xml --server.max-http-header-size=524288
nacos is starting with standalone
nacos is starting,you can check the /Users/apple/software/nacos/logs/start.out

日志末显示 "staring" 表示启动成功,打开http://127.0.0.1:8848/nacos,输入默认的用户名 nacos、密码 nacos,就可以看到如下界面。

可以看到 Nacos 的提供的主要功能已经在左侧菜单中标示出来,本次我们只用到服务管理功能,配置管理我们下个章节再讲。

关闭服务也很简单,执行提供的相应脚本即可。

Linux / Unix / Mac 平台下 sh shutdown.sh


Windows 平台下 cmd shutdown.cmd 或者双击 shutdown.cmd 运行文件。

到此,服务注册中心已经准备完毕,下面我们将服务注册到注册中心来。

服务中应用 Nacos

1、首先在父项目引入 Spring Cloud,Spring Cloud Alibaba jar 包的依赖,参考Spring Boot的引入方式。

考虑到三个项目之间的版本问题,本次采用 Greenwich.SR4 版本。

父项目 parking-project 的 pom.xml中增加如下配置:

xml 复制代码
<properties>
    <spring-cloud.version>Greenwich.SR4</spring-cloud.version>
    <spring-cloud-alibaba.version>2.1.0.RELEASE</spring-cloud-alibaba.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>${spring-cloud-alibaba.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

2、在子模块服务中引入 nacos jar 包,在子模块的 pom.xml 文件中增加如下配置

xml 复制代码
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    <version>0.2.2.RELEASE</version>
</dependency>
<dependency>
    <groupId>com.alibaba.nacos</groupId>
    <artifactId>nacos-client</artifactId>
</dependency>

模块启动类中加入 @EnableDiscoveryClient 注解,这与使用 Eureka 时,采用注解配置是一致的,此注解基于 spring-cloud-commons ,是一种通用解决方案。

在对应的项目配置文件 application.properties 中增加配置项:

复制代码
#必须填写application.name,否则服务无法注册到nacos
spring.application.name=card-service/member-service
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848

启动项目,通过nacos控制台检查服务是否注册到nacos,正常情况下能发现两个服务实例:

由于当前服务是由单机单实例的形式运行,图中标示出服务的集群数目为1、实例数为1、健康实例数为1,如果我们针对同一个服务,启动两实例,注册中心能即时的监控到,并展示出来。操作栏的"示例代码"链接,为我们提供了不同开发语言下的服务使用攻略,还是相当人性化的。在此也可以看出 Nacos 的未来肯定是跨语言的,不能局限在 Java 领域,这与微服务的语言无关性也是契合的。

这里做个测试,在 eclipse 项目 application.properties 配置文件中修改服务端口,保证同一个服务端口不冲突。

server.port=9090

启动3个实例,形成一个 card-service 服务的小集群,可以在控制台配置各个实例的权重,权重不同,在处理请求时响应的次数也会不同,实例的增多,大大提高了服务的响应效率。

至此,我们已经可以将一个服务注册到服务注册中心来统一管理配置,后续的其它服务都可以参照此方式,做好基础配置,将服务统一注册到 Nacos 注册中心管理维护,为后续的服务间调用打下基础。

留个思考动手题

文中我们采用的是单机单实例部署 Nacos 服务,生产环境中肯定不能采用单机模式,会存在单点故障,你能搭建一个真实的多机器实例集群吗?

07 如何调用本业务模块外的服务------服务调用

上篇已经引入 Nacos 基础组件,完成了服务注册与发现机制,可以将所有服务统一的管理配置起来,方便服务间调用。本篇将结合需求点,进行服务间调用,完成功能开发。

几种服务调用方式

服务间调用常见的两种方式:RPC 与 HTTP,RPC 全称 Remote Produce Call 远程过程调用,速度快,效率高,早期的 WebService 接口,现在热门的 Dubbo、gRPC 、Thrift、Motan 等,都是 RPC 的典型代表,有兴趣的小伙伴可以查找相关的资料,深入了解下。

HTTP 协议(HyperText Transfer Protocol,超文本传输协议)是因特网上应用最为广泛的一种网络传输协议,所有的 WWW 文件都必须遵守这个标准。对服务的提供者和调用方没有任何语言限定,更符合微服务语言无关的理念。时下热门的 RESTful 形式的开发方式,也是通过 HTTP 协议来实现的。

本次案例更多的考虑到简捷性以及 Spring Cloud 的基础特性,决定采用 HTTP 的形式,进行接口交互,完成服务间的调用工作。Spring Cloud 体系下常用的调用方式有:RestTemplate 、 Ribbon 和 Feign 这三种。

RestTemplate,是 Spring 提供的用于访问 Rest 服务的客户端,RestTemplate 提供了多种便捷访问远程 Http 服务的方法,能够大大提高客户端的编写效率。

Ribbon,由 Netflix 出品,更为人熟知的作用是客户端的 Load Balance(负载均衡)。

Feign,同样由 Netflix 出品,是一个更加方便的 HTTP 客户端,用起来就像调用本地方法,完全感觉不到是调用的远程方法。内部也使用了 Ribbon 来做负载均衡功能。

由于 Ribbon 已经融合在 Feign 中,下面就只介绍 RestTemplate 和 Feign 的使用方法。

RestTemplate 的应用

功能需求:会员绑定手机号时,同时给其增加相应的积分。会员绑定手机号在会员服务中完成,增加会员积分在积分服务中完成。请求路径是客户端->会员服务->积分服务。

响应客户端请求的方法

java 复制代码
@RequestMapping(value = "/bindMobileUseRestTemplate", method = RequestMethod.POST)
public CommonResult<Integer> bindMobileUseRestTemplate(String json) throws BusinessException{
   CommonResult<Integer> result = new CommonResult<>();
   log.info("bind mobile param = " + json);
   int rtn = memberService.bindMobileUseRestTemplate(json);
   result.setRespData(rtn);
   return result;
}

做好 RestTemplate 的配置工作,否则无法正常使用。

java 复制代码
@Configuration
public class RestTemplateConfig {
    @Bean
    public RestTemplate restTemplate(ClientHttpRequestFactory simpleClientHttpRequestFactory){
        return new RestTemplate(simpleClientHttpRequestFactory);
    }

    @Bean
    public ClientHttpRequestFactory simpleClientHttpRequestFactory(){
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        return factory;
    }
}

在 MemberService 中处理请求,逻辑如下:

java 复制代码
@Autowired
RestTemplate restTemplate;

@Override
public int bindMobileUseRestTemplate(String json) throws BusinessException {
    Member member = JSONObject.parseObject(json, Member.class);
    int rtn = memberMapper.insertSelective(member);
    // invoke another service
    if (rtn > 0) {
        MemberCard card = new MemberCard();
        card.setMemberId(member.getId());
        card.setCurQty("50");

        MultiValueMap<String, String> requestMap = new LinkedMultiValueMap<String, String>();
        requestMap.add("json", JSONObject.toJSONString(card).toString());
        HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<MultiValueMap<String, String>>(
                    requestMap, null);

        String jsonResult = restTemplate.postForObject("http://localhost:10461/card/addCard", requestEntity,
                    String.class);

        log.info("creata member card suc!" + jsonResult);
    }

    return rtn;
}

采用 postForObject 形式请求积分服务中的生成积分记录的方法,并传递相应参数。积分子服务中方法比较简单,接受调用请求的方法:

java 复制代码
@RequestMapping("card")
@RestController
@Slf4j
public class MemberCardController {

    @Autowired
    MemberCardService cardService;

    @RequestMapping(value = "/addCard", method = RequestMethod.POST)
    public CommonResult<Integer> addCard(String json) throws BusinessException {
        log.info("eclise service example: begin add member card = " + json);
        //log.info("jar service example: begin add member card = " + json);
        CommonResult<Integer> result = new CommonResult<>();
        int rtn = cardService.addMemberCard(json);
        result.setRespData(rtn);
        return result;
    }
}

实际业务逻辑处理部分由 MemberCardService 接口中完成。

java 复制代码
@Service
@Slf4j
public class MemberCardServiceImpl implements MemberCardService {

    @Autowired
    MemberCardMapper cardMapper;

    @Override
    public int addMemberCard(String json) throws BusinessException {
        MemberCard card = JSONObject.parseObject(json,MemberCard.class);
        log.info("add member card " +json);
        return cardMapper.insertSelective(card);
    }
}

分别启动会员服务、积分服务两个项目,通过 Swagger 接口 UI 作一个简单测试。

RestTemplate 默认依赖 JDK 提供 HTTP 连接的能力,针对 HTTP 请求,提供了不同的方法可供使用,相对于原生的 HTTP 请求是一个进步,但经过上面的代码使用,发现还是不够优雅。能不能像调用本地接口一样,调用第三方的服务呢?下面引入 Feign 的应用,绝对让你喜欢上 Feign 的调用方式。

Feign 的应用

Fegin 的调用最大的便利之处在于,屏蔽底层的连接逻辑,让你可以像调用本地接口一样调用第三方服务,代码量更少更优雅。当然,必须在服务注册中心的协调下才能正常完成服务调用,而 RestTemplate 并不关心服务注册中心是否正常运行。

引入 Feign

Feign 是由 Netflix 开发出来的另外一种实现负载均衡的开源框架,它封装了 Ribbon 和 RestTemplate,实现了 WebService 的面向接口编程,进一步的减低了项目的耦合度,因为它封装了 Riboon 和 RestTemplate ,所以它具有这两种框架的功能。 在会员模块的 pom.xml 中添加 jar 引用

xml 复制代码
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

在模块启动类中增加 [@EnableFeignClients] 注解,才能正常使用 Feign 相关功能,启动时开启对 @FeignClient 注解的包扫描,并且扫描到相关的接口客户端。

java 复制代码
//#client 目录为 feign 接口所在目录
@EnableFeignClients(basePackages = "com.mall.parking.member.client")

前端请求响应方法

java 复制代码
    @RequestMapping(value = "/bindMobile", method = RequestMethod.POST)
    public CommonResult<Integer> bindMobile(String json) throws BusinessException{
        CommonResult<Integer> result = new CommonResult<>();
        log.info("bind mobile param = " + json);
        int rtn = memberService.bindMobile(json);
        result.setRespData(rtn);
        return result;
    }
接口编写

编写 MemberCardClient,与积分服务调用时使用,其中的接口与积分服务中的相关方法实行一对一绑定。

java 复制代码
@FeignClient(value = "card-service")
public interface MemberCardClient {

    @RequestMapping(value = "/card/addCard", method = RequestMethod.POST)
    public CommonResult<Integer> addCard(@RequestParam(value = "json") String json) throws BusinessException;

    @RequestMapping(value = "/card/updateCard", method = RequestMethod.POST)
    public CommonResult<Integer> updateCard(@RequestParam(value = "json") String json) throws BusinessException;
}

注意是 RequestParam 不是 PathVariablePathVariable 是从路径中取值,RequestParam 是从参数中取值,用法不一。

使用时,直接 [@Autowired] 像采用本地接口一样使用即可,至此完成代码的编写,下面再来验证逻辑的准确性。

  1. 保证 nacos-server 启动中
  2. 分别启动 parking-member,parking-card 子服务
  3. 通过 parking-member 的 swagger-ui 界面,调用会员绑定手机号接口(或采用 PostMan 工具)

正常情况下,park-member,park-card 两个库中数据表均有数据生成。

那么,fallback 何时起作用呢?很好验证,当积分服务关闭后,再重新调用积分服务中的生成积分方法,会发现直接调用的是 MemberCardServiceFallback 中的方法,直接响应给调用方,避免了调用超时时,长时间的等待。

负载均衡

前文已经提到 Feign 中已经默认集成了 Ribbon 功能,所以可以通过 Feign 直接实现负载均衡。启动两个 card-service 实例,打开 Nacos 控制台,发现实例已经注册成功。再通过 swagger-ui 或 PostMan 工具访问多访问几次 bindMobile 方法,通过控制台日志输出,可以发现请求在两个 card-service 实例中轮番执行。

如何改变默认的负载均衡策略呢?先弄清楚 Ribbon 提供了几种负载策略:随机、轮询、重试、响应时间权重和最空闲连接,分别对应如下

com.netflix.loadbalancer.RandomRule com.netflix.loadbalancer.RoundRobinRule com.netflix.loadbalancer.RetryRule com.netflix.loadbalancer.WeightedResponseTimeRule com.netflix.loadbalancer.BestAvailableRule

由于是客户端负载均衡,必须配置在服务调用者项目中增加如下配置来达到调整的目的。

card-service: ribbon: NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule

也可以通过 Java 代码的形式调整,放置在项目启动类的下面

java 复制代码
      @Bean
    public IRule ribbonRule() {
        // 负载均衡规则,改为随机
        return new RandomRule();
//        return new BestAvailableRule();
//        return new WeightedResponseTimeRule();
//        return new RoundRobinRule();
//        return new RetryRule();
    }

至此,我们通过一个"会员绑定手机号,并增加会员相应积分"的功能,通过两种方式完成一个正常的服务调用,并测试了客户端负载均衡的配置及使用。

课外作业

掌握了服务间调用后,在不考虑非业务功能的情况下,基本可以将本案例中大部分业务逻辑代码编写完成,可参照自己拆解的用户故事,或者主要的业务流程图,现在就动手,把代码完善起来吧。

服务调用是微服务间频繁使用的功能,选定一个简捷的调用方式尤其重要。照例留下一道思考题吧:本文用到了客户端负载均衡技术,它与我们时常提到的负载均衡技术有什么不同吗?

08 服务响应慢或服务不可用怎么办------快速失败与服务降级

上个章节已经基于 OpenFeign 完成了微服务间的调用,并且在多实例集群的情况下,通过调整负载策略很好应对并发调用。网络产品开发时,网络有时可能是不可用的,服务亦有可能是不可用的,当调用服务响应慢或不可用时,大量的请求积压,会成为压倒系统骆驼的最后一根稻草。这种情况下,我们如何应对呢?本章节就带你走近 Hystrix 组件。

什么是 Hystrix

它是分布式系统提供的一个低时延容错机制的基础组件,提供限流、服务降级、系统熔断保护、快速失败等多个维度来保障微服务的稳定性。Hystrix 也是 Netflix 套件的一部分。

遗憾的是 1.5.18 版本之后进入了维护模式,官方提供了替代方案:resilience4j,本测试采用的 Hystrix 终极版,需要更高版本的话,建议还是采用 resilience4j ,这里不作过多介绍,后续将替换成另一个重要组件------Sentinel 来替代 Hystrix。

引入 Hystrix

采用 starter 的方式引入

xml 复制代码
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

feignClient 中已经默认集成了断路器的功能,但是需要在配置文件中打开,才能开启。在 application.properties 中打开 hystrix 开关:

properties 复制代码
#hystrix enable
feign.hystrix.enabled=true

重新回到之前的 FeignClient 代码,在注解中增加 fallback 属性值,添加相应的 fallback 调用类。

java 复制代码
@FeignClient(value = "card-service", fallback = MemberCardServiceFallback.class)
public interface MemberCardClient {

    @RequestMapping(value = "/card/addCard", method = RequestMethod.POST)
    public CommonResult<Integer> addCard(@RequestParam(value = "json") String json) throws BusinessException;

    @RequestMapping(value = "/card/updateCard", method = RequestMethod.POST)
    public CommonResult<Integer> updateCard(@RequestParam(value = "json") String json) throws BusinessException;
}

编写 MemberCardServiceFallback 方法,就是一个普通的服务实现类,增加了[@Component] 注解。

java 复制代码
@Component
@Slf4j
public class MemberCardServiceFallback implements MemberCardClient {

    @Override
    public CommonResult<Integer> addCard(String json) throws BusinessException {
        CommonResult<Integer> result = new CommonResult<>("parking-card service not available! ");
        log.warn("parking-card service not available! ");
        return result;
    }

    @Override
    public CommonResult<Integer> updateCard(String json) throws BusinessException {
        CommonResult<Integer> result = new CommonResult<>("parking-card service not available! ");
        log.warn("parking-card service not available! ");
        return result;
    }
}

测试 Hystrix

上一章节中按正常流程已经将功能完成:会员开通后,积分生成,这里将不启动"积分子服务",看看会是什么效果。(默认服务注册中心已经启动,这里及后续演示过程中不再专门提出)

  1. 只启动 parking-member 一个子服务
  2. 打开 parking-member 子服务的 swagger-ui 界面,调用会员绑定手机号接口(或采用 PostMan 工具)

正常情况下会直接调用 fallback 接口,快速失败,响应调用方。

此时将积分模块服务启动,再次发起调用,正确情况下已不再调用 fallback 方法,而是正常调用积分服务接口,如下图所示:

图形化监控 Hystrix

通过上面的应用,我们已经可以将 Hystrix 正常的集成到功能开发过程中,但究竟 Hystrix 实时运行状态是什么样的呢?有没有什么办法可以看到 Hystrix 的各项指标呢?这里我们引入 Hystrix Dashboard (仪表盘),通过 UI 的方式,快速的查看运行状况。

新增仪盘表项目

我们在 parking-base-serv 项目下,新建一个名为 parking-hystrix-dashboard Spring Boot 子工程,专门来做 Hystrix 的仪表盘监控。修改 pom.xml 文件,添加相关依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-hystrix-dashboard</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

在项目启动类中增加 @EnableHystrixDashboard 注解,开启仪表盘功能

java 复制代码
@SpringBootApplication
@EnableDiscoveryClient
@EnableHystrixDashboard
public class ParkingHystrixDashboardApplication {

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

启动项目,打开地址:http://localhost:10093/hystrix,出现如下界面表明正常运行。

调整被监控项目

会员服务中在调用积分服务接口的过程中,采用 Feign 的方式发起远程调用,同时实现了 fallback 服务降级、快速失败功能,本次要监控的主要目标就是此功能。

在 parking-member 项目 config 代码包下,增加 Hystrix 数据流的配置:

java 复制代码
@Configuration
public class HystrixConfig {

    @Bean
    public ServletRegistrationBean<HystrixMetricsStreamServlet> getServlet() {
        HystrixMetricsStreamServlet servlet = new HystrixMetricsStreamServlet();
        ServletRegistrationBean<HystrixMetricsStreamServlet> bean = new ServletRegistrationBean<>(servlet);
        bean.addUrlMappings("/hystrix.stream");
        bean.setName("HystrixMetricsStreamServlet");
        return bean;
    }
}

启动后,打开本项目的 Hystrix 数据获取地址:http://localhost:10060/hystrix.stream,初始状态,页面会不停的输出 ping 空值的情况,只有采用 Hystrix 当相关功能被请求时,才能正常的输出数据 JSON 格式数据,如截图所示:

上图输出的结果不够友好,没有办法直观的分析 Hystrix 组件的应用情况,此时我们的仪表盘项目就派上用场。

仪表盘解读

将地址 http://localhost:10060/hystrix.stream 输入到 dashboard 页面中数据抓取地址栏中,Delay 项可采用默认值,Titile 项可以新取一个名字,便于我们能够识别。同样的,相关功能只有被执行过,仪表盘中才能正常的显示,下图所示是由于积分服务未启动,会员服务直接调用导致全部失败的情况。

关于图表简单解读下:

  • 左上角的圆圈表示服务的健康程度,从绿色、黄色、橙色、红色递减
  • 曲线用来记录 2 分钟内流量的相对变化,观察流量的上升和下降趋势。
  • 左侧框中的数字与右上数字含义是一一对应
  • Host 与 Cluster 记录的是服务请求频率
  • 再下面的几个 *th 标签表示百分位的延迟情况

(恢复积分服务后,高频次重新调用功能,发现请求是正常的,圆圈也变大)

本案例中仅编写了一个 Hystrix 的应用情况,如果服务中多处使用的话,仪表盘的展现会更加丰富,从页面上可以清晰监控到服务的压力情况、运转情况等等,为运维工作提供重要的参照依据。

(图中参数展现略有不同,图片来源于https://github.com/Netflix-Skunkworks/hystrix-dashboard)

通过上文的学习实践,相信你对 Hystrix 断路器的应用有了初步的概念,以及如何应用到项目中去,为我们的服务提供保驾护航。

留一个思考题

文中仅展示了一个模块服务的断路器的应用,如果是多个服务需要监控怎么办?同时打开多个仪表盘页面吗?你有没有什么更好的办法?

09 热更新一样更新服务的参数配置------分布式配置中心

几乎每个项目中都涉及到配置参数或配置文件,如何避免硬编码,通过代码外的配置,来提高可变参数的安全性、时效性,弹性化配置显得尤其重要。本篇就带你一起聊聊软件项目的配置问题,特别是微服务架构风格下的配置问题。

参数配置的演变

早期软件开发时,当然也包括现在某些小伙伴开发时,存在硬编码的情况,将一些可变参数写死在代码中。弊端也显而易见,当参数变更时,必须重新构建编译代码,维护成本相当高。

后来,业界形成规则,将一些可变参数抽取出来,形成多种格式的配置文件,如 properties、yml、json、xml 等等,将这些参数集中管理起来,发生变更时,只需要更新配置文件即可,不再需要重新编码代码、构建发布代码块,明显比硬编码强大太。弊端也有:

  • 关键信息暴露在配置文件中,安全性低。
  • 配置文件变更后,服务也面临重启的问题

再接着出现了分布式配置,将配置参数从项目中解耦出来,项目使用时,及时向配置中心获取或配置中心变更时向项目中推送,优势很明显:

  1. 省去了关键信息暴露的问题
  2. 配置参数无须与代码模块耦合在一起,可以灵活的管理权限,安全性更高
  3. 配置可以做到实时生效,对一些规则复杂的代码场景很有帮助
  4. 面对多环境部署时,能够轻松应对

微服务场景下,我们也更倾向于采用分布式配置中心的模式,来管理配置,当服务实例增多时,完全不用担心配置变得复杂。

开源组件介绍

Spring Cloud Config 就是 Spring Cloud 项目下的分布式配置组件,当然其自身是没有办法完成配置功能的,需要借助 Git 或 MQ 等组件来共同完成,复杂度略高。

业内不少公司开源不少分布式配置组件,比如携程的 Apollo(阿波罗),淘宝的 Diamond,百度的 Disconf,360 的 QConf ,阿里的 Nacos 等等,也可以基于 Zookeeper 等组件进行开发完成,技术选型产品还是比较多的。

本案例中采用 Nacos 作为选型,为什么选 Nacos ?首先是 Spring Cloud Alibaba 项目下的一员,与生态贴合紧密 。其次,Nacos 作为服务注册中心,已经在项目使用,它兼有配置中心的功能,无须额外引入第三方组件,增加系统复杂度,一个组件完成 Spring Cloud Config 和 Eureka 两个组件的功能。

Nacos 在基础层面通过DataIdGroup来定位唯一的配置项,支持不同的配置格式,如 JSON , TEXT , YMAL 等等,不同的格式,遵从各自的语法规则即可。

Nacos 配置管理

拿用户手机号绑定系统的功能为例,商场做促销活动,当天用户绑定手机号,并开通月卡,积分赠送翻倍,还有机会抽取活动大礼包,活动结束,恢复成原样。这是经常见到的玩法吧。

在 parking-member 项目的 pom.xml 中增加 jar 引入,不过前期我们已经应用到了 nacos 的服务注册中心功能,已经被引入到项目中去。

xml 复制代码
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

下面就将将与 nacos 的连接配置进去,由于 Spring Boot 项目中存在两种配置文件,一种是 application 的配置,一种是 bootstrap 的配置,究竟配置在哪个文件中呢?我们先来看 bootstrap 与 application 有什么区别。

(截图来源于官方文档:https://cloud.spring.io/spring-cloud-static/Greenwich.SR1/single/spring-cloud.html#the bootstrapapplicationcontext)

nacos 与 spring-cloud-config 配置上保持一致,必须将采用 bootstrap.yml/properties 文件,优先加载该配置,填写在 application.properties/yml 中无效。

bootstrap.properties

properties 复制代码
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.profiles.active=dev
spring.application.name=member-service
spring.cloud.nacos.config.shared-data-ids=${spring.application.name}-${spring.profiles.active}.properties
spring.cloud.nacos.config.refreshable-dataids=${spring.application.name}-${spring.profiles.active}.properties

此时采用 spring.application.name−spring.application.name−{spring.profiles.active}动态配置的原因,是为后期进行多环境构建做准备,当然也可以直接写死 nacos 配置文件,但这样更不利于扩展维护。refreshable-dataids 配置项为非选必须,但如果缺失,所有的 nacos 配置项将不会被自动刷新,必须采用另外的方式刷新配置项,才能正常应用到服务中。

代码中使用配置项

打开会员绑定手机号的方法处,定义一个内部变量,接受配置中心的参数值。可以直接采用 Spring 的 [@Value ]方式取值即可,在 Controller 层或是 Service 层都可以使用,类中必须采用 [@RefreshScope ]注解,才能将值实时同步过来。

在 nacos 中定义两个配置项 onoffbindmobile 和 onbindmobile_mulriple,分别代表一个开关和积分倍数。同时定义两个局部变量接受 nacos 中两个配置项的值。

即使参数外部化配置后,代码中也必须预留出位置,供代码逻辑进行切换,也就是事先这个逻辑是预置进去的,是否执行逻辑,逻辑中相关分支等等,完全看配置参数的值。 比如注册时,往往会夹杂一些送积分、注册送优惠券及多大面额的优惠券,注册有机会抽奖等,等一个活动结束自动切换到另一个活动,如果事先未预置进去,代码是没有办法执行这个逻辑的。

整体代码如下:

java 复制代码
@Value("${onoff_bindmobile}")
private boolean onoffBindmobile;

@Value("${on_bindmobile_mulriple}")
private int onBindmobileMulriple;

@Override
public int bindMobile(String json) throws BusinessException{
    Member member = JSONObject.parseObject(json, Member.class);
    int rtn = memberMapper.insertSelective(member);

    //invoke another service
    if (rtn > 0) {
        MemberCard card = new MemberCard();
        card.setMemberId(member.getId());
        //判定开关是否打开
        if (onoffBindmobile) {
            //special logic
            card.setCurQty("" +50 * onBindmobileMulriple);
        }else {
            //normal logic
            card.setCurQty("50");
        }

        memberCardClient.addCard(JSONObject.toJSONString(card));
        log.info("creata member card suc!");
    }
    
    return rtn;
}
Nacos 中配置参数项

bootstrap 配置文件中已经做了约定:配置项的 Key 与 Value 值的格式为 properties 文件,打开 nacos 控制台,进行"配置管理"->"配置列表"页面,点击右上角"新增"。

输入上文的约定的两个配置项,配置项可以应对多环境配置要求,在其它环境,建立对应的配置即可,输入 Data ID, 比如 member-service-prd.properties 或者 member-service-uat.properties,Group 采用默认值即可,当然如果需要分组的话,需要定义 Group 值,保存即可。

配置项的 value 类型需要与代码中约定的一致,否则解析时会出错。

若要变更配置项的值,修改后,发布即可,可以看到配置项的值是立即生效的。主要是借助于上文中提到的 [@RefreshScope ]注解,与 bootstrap 配置文件中 spring.cloud.nacos.config.refreshable-dataids 配置来完成的。如果缺失其中的一个,配置项生效只有重启应用,这与我们打造程序的易维护性、健壮性是相违背的。

可以将相应的数据库连接、第三方应用接入的密钥、经常发生变更的参数项都填充到配置中心,借助配置中心自身的权限控制,可以确保敏感项不泄露,同时针对不同的部署环境也可以很好的做好配置隔离。

至此,基于 Nacos 的配置中心配置完成,依照此配置,可以正常的迁移到其它服务中去。留下个思考题,在项目中采用类似 properties 文件的外部化配置,还是比较常见的,如何确保当中的关键信息不被泄漏呢,比如数据库的用户名、密码,第三方核心的 Key 等等。

10 如何高效读取计费规则等热数据------分布式缓存

前几章节主要聚集于会员与积分模块的业务功能,引领大家尝试了服务维护、配置中心、断路器、服务调用等常见的功能点,本章节开始进入核心业务模块------停车计费,有两块数据曝光率特别高:进场前的可用车位数和计费规则,几乎每辆车都进出场都用到,这部分俗称为热数据:经常会用到。读关系库很明显不是最优解,引入缓存才是王道。

分布式缓存

这里仅讨论软件服务端的缓存,不涉及硬件部分。缓存作为互联网分布式开发两大杀器之一(另一个是消息队列),应用场景相当广泛,遇到高并发、高性能的案例,几乎都能看到缓存的身影。

从应用与缓存的结合角度来区分可以分为本地缓存和分布式缓存。

我们经常用 Tomcat 作为应用服务,用户的 session 会话存储,其实就是缓存,只不过是本地缓存,如果需要实现跨 Tomcat 的会话应用,还需要其它组件的配合。Java 中我们应经用到的 HashMap 或者 ConcurrentHashMap 两个对象存储,也是本地缓存的一种形式。Ehcache 和 Google Guava Cache 这两个组件也都能实现本地缓存。单体应用中应用的比较多,优势很明显,访问速度极快;劣势也很明显,不能跨实例,容量有限制。

分布式场景下,本地缓存的劣势表现的更为突出,与之对应的分布式缓存则更能胜任这个角色。软件应用与缓存分离,多个应用间可以共享缓存,容量扩充相对简便。有两个开源分布式缓存产品:memcached 和 Redis。简单介绍下这两个产品。

memcached 出现比较早的缓存产品,只支持基础的 key-value 键值存储,数据结构类型比较单一,不提供持久化功能,发生故障重启后无法恢复,它本身没有成功的分布式解决方案,需要借助于其它组件来完成。Redis 的出现,直接碾压 memcached ,市场占有率节节攀升。

Redis 在高效提供缓存的同时,也支持持久化,在故障恢复时数据得已保留恢复。支持的数据类型更为丰富,如 string , list , set , sorted set , hash 等,Redis 自身提供集群方案,也可以通过第三方组件实现,比如 Twemproxy 或者 Codis 等等,在实际的产品应用中占有很大的比重。另外 Redis 的客户端资源相当丰富,支持近 50 种开发语言。

本案例中的热数据采用 Redis 来进行存储,在更复杂的业务功能时,可以采用本地缓存与分布式缓存进行混合使用。

Redis 应用

Redis 安装配置

官网地址:https://redis.io/,当前最新版已到 5.0.7,Redis 提供了丰富了数据类型、功能特性,基本能够覆盖日常开发运维使用,简单的命令行使学习曲线极低,可以快速上手实践。提供了丰富语言客户端,供使用者快速的集成到项目中。

(图片来源于 redis 官网,https://redis.io/clients)

下面来介绍如何安装 redis:

  1. 下载编译过的二进制安装包,本案例中使用的版本是 4.0.11。
shell 复制代码
$ wget http://download.redis.io/releases/redis-4.0.11.tar.gz
$ tar xzf redis-4.0.11.tar.gz
$ cd redis-4.0.11
$ make
  1. 配置,默认情况下 redis 的的配置安全性较弱,无密码配置的,端口易扫描。若要修改默认配置,可修改 redis.conf 文件。

// 可以修改默认端口 6379

port 16479

// redis 默认情况下不是以后台程序的形式运行,需要将开关打开

daemonize yes

// 打开需要密码开发,设置密码

requirepass zxcvbnm,./

  1. 启动 redis

// 启动时,加载配置文件

appledeMacBook-Air:redis-4.0.11 apple$ src/redis-server redis.conf 59464:C 07 Mar 10:38:15.284 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo 59464:C 07 Mar 10:38:15.285 # Redis version=4.0.11, bits=64, commit=00000000, modified=0, pid=59464, just started 59464:C 07 Mar 10:38:15.285 # Configuration loaded

// 命令行测试

appledeMacBook-Air:redis-4.0.11 apple$ src/redis-cli -p 16479

// 必须执行 auth 命令,输入密码,否则无法正常使用命令

127.0.0.1:16479> auth zxcvbnm,./ OK 127.0.0.1:16479> dbsize (integer) 51 127.0.0.1:16479>

至此,redis 服务安装完成,下面就可以将缓存功能集成到项目中去。有小伙伴可能会说通过命令方式操作 redis 远不如图形化管理界面直观,活跃的同学们早已提供对应的工具供大家使用,比如 Redis Manager 等。

集成 Spring Data Redis

此次实践采用 Spring Data 项目家族中的 Spring Data Redis 组件与 Redis Server 进行交互通信,与 Spring Boot 项目集成时,采用 starter 的方式进行。

Spring Boot Data Redis 依赖于 Jedis 或 lettuce 客户端,基于 Spring Boot 提供一套与客户端无关的 API ,可以轻松将一个 redis 切换到另一个客户端,而不需要修改业务代码。

计费业务对应的项目模块是 parking-charging,在 pom.xml 文件中引入对应的 jar,这里为什么没有 version 呢,其实已经在 spring-boot-dependencies 配置中约定,此处无须再特殊配置。

xml 复制代码
<!-- 鼠标放置上面有弹出信息提示:The managed version is 2.1.11.RELEASE The artifact is managed in org.springframework.boot:spring-boot-dependencies:2.1.11.RELEASE -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

可以通过编写 Java 代码,进行 [@Configuration] 注解配置,也可以使用配置文件进行。这里使用配置文件的方式。在 application.properties 中配置 redis 连接,这里特殊指定了 database ,Redis 默认有 16 个数据库,从 0 到 15 ,可以提供有效的数据隔离,防止相互污染。

properties 复制代码
#redis config
spring.redis.database=1
spring.redis.host=localhost
spring.redis.port=16479
#default redis password is empty
spring.redis.password=zxcvbnm,./
spring.redis.timeout=60000
spring.redis.pool.max-active=1000
spring.redis.pool.max-wait=-1
spring.redis.pool.max-idle=10
spring.redis.pool.min-idle=5

基于 Spring Boot 的约定优于配置的原则,按如下方式配置后,redis 已经可以正常的集成在项目中。

编写服务类 RedisServiceImpl.java ,基于 Spring Boot Data Redis 项目中封装的 RedisTemplate 就可以与 redis 进行通信交互,本示例仅以简单的基于 string 数据格式的 key-value 方式进行。

java 复制代码
@Slf4j
@Service
public class RedisServiceImpl implements RedisService {

    @Autowired
    RedisTemplate<Object, Object> redisTemplate;

    @Override
    public boolean exist(String chargingrule) {
        ValueOperations<Object, Object> valueOperations = redisTemplate.opsForValue();
        return valueOperations.get(chargingrule) != null ? true : false;
    }

    @Override
    public void cacheObject(String chargingrule, String jsonString) {
        redisTemplate.opsForValue().set(chargingrule, jsonString);
        log.info("chargingRule cached!");
    }

}

redis 对比 memcached 支持的数据类型更为丰富,RedisTemplate 的 API 中同样提供了对应的操作方法,如下:

加载数据至缓存中

项目第一次启动,如何将数据库写入 cache 中去的呢?建议在项目启动时就加载缓存,待数据变更后再回刷缓存。项目启动后就加载,Spring Boot 提供了两种方式在项目启动时就加载的方式供大家使用:ApplicationRunner 与 CommandLineRunner,都是在 Spring 容器初始化完毕之后执行起 run 方法,两者最明显的区别就是入参不同。

本例子中采用 ApplicationRunner 方式

初始化计费规则 cache :

java 复制代码
@Component
@Order(value = 1)//order 是加载顺序,越小加载越早,若有依赖关于,建议按顺序排列即可
public class StartUpApplicationRunner implements ApplicationRunner {

    @Autowired
    RedisService redisService;

    @Autowired
    ChargingRuleService ruleService;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<ChargingRule> rules = ruleService.list();
    //ParkingConstant 为项目中常量类
        if (!redisService.exist(ParkingConstant.cache.chargingRule)) {
            redisService.cacheObject(ParkingConstant.cache.chargingRule, JSONObject.toJSONString(rules));
        }
    }
}

项目启动后,用 redis 客户端查看缓存中是否有数据。

复制代码
appledeMacBook-Air:redis-4.0.11 apple$ src/redis-cli -p 16479
127.0.0.1:16479> auth zxcvbnm,./
OK
127.0.0.1:16479> select 1
OK
127.0.0.1:16479[1]> keys *
1) "\xac\xed\x00\x05t\x00\aruleKey"

发现 Key 值前面有一堆类似乱码的东西 \xac\xed\x00\x05t\x00\a,这是 unicode 编码, 由于 redisTemplate 默认的序列化方式为 jdkSerializeable,存储时存储二进制字节码,但不影响数据。此处需要进行重新更改序列化方式,以便按正常方式读取。

java 复制代码
@Component
public class RedisConfig {

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(Object.class);

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);

        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
//重新设置值序列化方式
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
//重新设置 key 序列化方式,StringRedisTemplate 的默认序列化方式就是 StringRedisSerializer      
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

将计费规则清除,采用 flushdb(慎用,会清楚当前 db 下的所有数据,另一个 flush 命令会将所有库清空,更要慎用)重新启动项目,再次加载计费规则。

复制代码
appledeMacBook-Air:redis-4.0.11 apple$ src/redis-cli -p 16479
127.0.0.1:16479> auth zxcvbnm,./
OK
127.0.0.1:16479> select 1
OK
127.0.0.1:16479[1]> keys *
1) "ruleKey"
127.0.0.1:16479[1]> get ruleKey
"\"[{\\\"createBy\\\":\\\"admin\\\",\\\"createDate\\\":1577467568000,\\\"end\\\":30,\\\"fee\\\":0.0,\\\"id\\\":\\\"41ed927623cf4a0bb5354b10100da992\\\",\\\"remark\\\":\\\"30\xe5\x88\x86\xe9\x92\x9f\xe5\x86\x85\xe5\x85\x8d\xe8\xb4\xb9\\\",\\\"start\\\":0,\\\"state\\\":1,\\\"updateDate\\\":1577467568000,\\\"version\\\":0},{\\\"createBy\\\":\\\"admin\\\",\\\"createDate\\\":1577467572000,\\\"end\\\":120,\\\"fee\\\":5.0,\\\"id\\\":\\\"41ed927623cf4a0bb5354b10100da993\\\",\\\"remark\\\":\\\"2\xe5\xb0\x8f\xe6\x97\xb6\xe5\x86\x85\xef\xbc\x8c5\xe5\x85\x83\\\",\\\"start\\\":31,\\\"state\\\":1,\\\"updateDate\\\":1577467572000,\\\"version\\\":0},{\\\"createBy\\\":\\\"admin\\\",\\\"createDate\\\":1577468046000,\\\"end\\\":720,\\\"fee\\\":10.0,\\\"id\\\":\\\"4edb0820241041e5a0f08d01992de4c0\\\",\\\"remark\\\":\\\"2\xe5\xb0\x8f\xe6\x97\xb6\xe4\xbb\xa5\xe4\xb8\x8a12\xe5\xb0\x8f\xe6\x97\xb6\xe4\xbb\xa5\xe5\x86\x85\xef\xbc\x8c10\xe5\x85\x83\\\",\\\"start\\\":121,\\\"state\\\":1,\\\"updateDate\\\":1577468046000,\\\"version\\\":0},{\\\"createBy\\\":\\\"admin\\\",\\\"createDate\\\":1577475337000,\\\"end\\\":1440,\\\"fee\\\":20.0,\\\"id\\\":\\\"7616fb412e824dcda41ed9367feadfda\\\",\\\"remark\\\":\\\"12\xe6\x97\xb6\xe8\x87\xb324\xe6\x97\xb6\xef\xbc\x8c20\xe5\x85\x83\\\",\\\"start\\\":721,\\\"state\\\":1,\\\"updateDate\\\":1577475337000,\\\"version\\\":0}]\""

此时 key 已正常显示,但 key 对应的 value 中显示依然有 unicode 编码,可在命令行中 增加 ---raw 参数来查看中文。完全命令行:src/redis-cli -p 16479 ---raw,中文就可以正常显示在客户端中。

使用缓存计费规则计算费用

在车辆出场时,要计算停靠时间,根据停车时间长久匹配具体的计费规则计算费用,然后写支付记录。

java 复制代码
/**
 * @param stayMintues
 * @return
*/
private float caluateFee(long stayMintues) {
    String ruleStr = (String) redisService.getkey(ParkingConstant.cache.chargingRule);
    JSONArray array = JSONObject.parseArray(ruleStr);
    List<ChargingRule> rules = JSONObject.parseArray(array.toJSONString(), ChargingRule.class);
    float fee = 0;
    for (ChargingRule chargingRule : rules) {
        if (chargingRule.getStart() <= stayMintues && chargingRule.getEnd() > stayMintues) {
            fee = chargingRule.getFee();
            break;
        }
    }
    return fee;
}

由于停车收费的交易压力并非很大,此处也仅作为案例应用,读库与读缓存的差距并不大。想象一下手机扣费的场景,如果还是读取关系库里的数据,再去计费,这个差距就有天壤之别了。

由于是分布式缓存,缓存已经与应用分离,任何一个项目,只有与 redis 取得合法连接,都可以任意取用缓存中的数据,当然 Redis 作为缓存是一个基本功能,其它也提供了很多操作,如数据分片、分布式锁、事务、内存优化、消息订阅/发布等,来应对不同业务场景下的需要。

留一个思考题

如何结合 Redis 来设计电商网站中常见的商品销榜单,如日热销榜,周热销榜,月热销榜,年热销榜等。

11 多实例下的定时任务如何避免重复执行------分布式定时任务

前面的章节,用户通过绑定手机号的注册为会员,并可以补充完个人信息,比如姓名、生日等信息,拿到用户的生日信息之后,就可以通过会员生日信息进行营销,此处就涉及到定时任务执行营销信息推送的问题。本篇就带你走入微服务下的定时任务的构建问题。

定时任务选型

常见的定时任务的解决方案有以下几种:

右半部分基于 Java 或 Spring 框架即可支持定时任务的开发运行,左侧部分需要引入第三方框架支持。针对不同方案,作个简单介绍

  • XXL-JOB 是一个分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。任务调度与任务执行分离,功能很丰富,在多家公司商业产品中已有应用。官方地址:https://www.xuxueli.com/xxl-job/
  • Elastic-Job 是一个分布式调度解决方案,由两个相互独立的子项目 Elastic-Job-Lite 和 Elastic-Job-Cloud 组成。Elastic-Job-Lite 定位为轻量级无中心化解决方案,依赖 Zookeeper ,使用 jar 包的形式提供分布式任务的协调服务,之前是当当网 Java 应用框架 ddframe 框架中的一部分,后分离出来独立发展。
  • Quartz 算是定时任务领域的老牌框架了,出自 OpenSymphony 开源组织,完全由 Java 编写,提供内存作业存储和数据库作业存储两种方式。在分布式任务调度时,数据库作业存储在服务器关闭或重启时,任务信息都不会丢失,在集群环境有很好的可用性。
  • 淘宝出品的 TBSchedule 是一个简洁的分布式任务调度引擎,基于 Zookeeper 纯 Java 实现,调度与执行同样是分离的,调度端可以控制、监控任务执行状态,可以让任务能够被动态的分配到多个主机的 JVM 中的不同线程组中并行执行,保证任务能够不重复、不遗漏的执行。
  • Timer 和 TimerTask 是 Java 基础组件库的两个类,简单的任务尚可应用,但涉及到的复杂任务时,建议选择其它方案。
  • ScheduledExecutorService 在 ExecutorService 提供的功能之上再增加了延迟和定期执行任务的功能。虽然有定时执行的功能,但往往大家不选择它作为定时任务的选型方案。
  • @EnableScheduling\] 以注解的形式开启定时任务,依赖 Spring 框架,使用简单,无须 xml 配置。特别是使用 Spring Boot 框架时,更加方便。

建立定时任务项目

在 parking-project 父项目中新增基于 Spring Boot 的定时任务项目,命名为 parking-schedule-job,将基本的项目配置完毕,如端口、项目名称等等。

新增项目启动类

java 复制代码
@SpringBootApplication
@EnableScheduling
public class ParkingScheduleJobApplication {

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

新增任务执行类

java 复制代码
@Component
@Slf4j
public class UserBirthdayBasedPushTask {

  //每隔 5s 输出一次日志
    @Scheduled(cron = " 0/5 * * * * ?")
    public void scheduledTask() {

        log.info("Task running at = "  + LocalDateTime.now());
    }
}

一个简单的定时任务项目就此完成,启动项目,日志每隔 5s 输出一次。单实例执行没有问题,但仔细想想似乎不符合我们的预期:微服务架构环境下,进行横向扩展部署多实例时,每隔 5s 每个实例都会执行一次,重复执行会导致数据的混乱或糟糕的用户体验,比如本次基于会员生日推送营销短信时,用户会被短信轰炸,这肯定不是我们想看到的。即使部署了多代码实例,任务在同一时刻应当也只有任务执行才是符合正常逻辑的,而不能因为实例的增多,导致执行次数增多。

分布式定时任务

保证任务在同一时刻只有执行,就需要每个实例执行前拿到一个令牌,谁拥有令牌谁有执行任务,其它没有令牌的不能执行任务,通过数据库记录就可以达到这个目的。

有小伙伴给出的是 A 方案,但有一个漏洞:当 select 指定记录后,再去 update 时,存在时间间隙,会导致多个实例同时执行任务,建议采用直接 update 的方案 B 更为可靠, update 更新到记录时会返回 1 ,否则是 0 。

这种方案还需要编写数据更新操作方法,如果这些代码都不想写,有没有什么好办法?当然有,总会有"懒"程序员帮你省事,介绍一个组件 ShedLock,可以使我们的定时任务在同一时刻,最多执行一次。

1、引入 ShedLock 相关的 jar ,这里依旧采用 MySQL 数据库的形式:

xml 复制代码
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-core</artifactId>
    <version>4.5.0</version>
</dependency>
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
    <version>4.5.0</version>
</dependency>
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-jdbc-template</artifactId>
    <version>4.5.0</version>
</dependency>

2、变更项目启动类,增加 [@EnableSchedulerLock] 注解,打开 ShedLock 获取锁的支持。

java 复制代码
@SpringBootApplication
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "30s")
public class ParkingScheduleJobApplication {

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

    @Bean
  //基于 Jdbc 的方式提供的锁机制
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(dataSource);
    }

}

3、任务执行类的方法上,同样增加 [@SchedulerLock] 注解,并声明定时任务锁的名称,如果有多个定时任务,要确保名称的唯一性。

4、新增名为 shedlock 的数据库,并新建 shedlock 数据表,表结构如下:

sql 复制代码
CREATE TABLE shedlock(
      `NAME` varchar(64) NOT NULL DEFAULT '' COMMENT '任务名',
      `lock_until` timestamp(3) NULL DEFAULT NULL COMMENT '释放时间',
      `locked_at` timestamp(3) NULL DEFAULT NULL COMMENT '锁定时间',
      `locked_by` varchar(255) DEFAULT NULL COMMENT '锁定实例',
    PRIMARY KEY (name)
)

5、修改 application.properties 中数据库连接

properties 复制代码
spring.datasource.driverClassName = com.mysql.cj.jdbc.Driver
spring.datasource.url = jdbc:mysql://localhost:3306/shedlock?useUnicode=true&characterEncoding=utf-8
spring.datasource.username = root
spring.datasource.password = root

6、完成以上步骤,基本配置已经完成,来测试一下,在多实例运行时,同一时刻是否只有一个实施在执行任务。

复制代码
//实例 1 的日志输出
2020-03-07 21:20:45.007  INFO 67479 --- [   scheduling-1] c.m.p.s.j.t.UserBirthdayBasedPushTask    : Task running at = 2020-03-07T21:20:45.007
2020-03-07 21:20:50.011  INFO 67479 --- [   scheduling-1] c.m.p.s.j.t.UserBirthdayBasedPushTask    : Task running at = 2020-03-07T21:20:50.011
2020-03-07 21:21:15.009  INFO 67479 --- [   scheduling-1] c.m.p.s.j.t.UserBirthdayBasedPushTask    : Task running at = 2020-03-07T21:21:15.009
2020-03-07 21:21:30.014  INFO 67479 --- [   scheduling-1] c.m.p.s.j.t.UserBirthdayBasedPushTask    : Task running at = 2020-03-07T21:21:30.014
2020-03-07 21:21:40.008  INFO 67479 --- [   scheduling-1] c.m.p.s.j.t.UserBirthdayBasedPushTask    : Task running at = 2020-03-07T21:21:40.008

//实例 2 的日志输出
2020-03-07 21:21:20.011  INFO 67476 --- [   scheduling-1] c.m.p.s.j.t.UserBirthdayBasedPushTask    : Task running at = 2020-03-07T21:21:20.011
2020-03-07 21:21:25.008  INFO 67476 --- [   scheduling-1] c.m.p.s.j.t.UserBirthdayBasedPushTask    : Task running at = 2020-03-07T21:21:25.008
2020-03-07 21:21:30.006  INFO 67476 --- [   scheduling-1] c.m.p.s.j.t.UserBirthdayBasedPushTask    : Task running at = 2020-03-07T21:21:30.006
2020-03-07 21:21:35.006  INFO 67476 --- [   scheduling-1] c.m.p.s.j.t.UserBirthdayBasedPushTask    : Task running at = 2020-03-07T21:21:35.006
2020-03-07 21:21:45.008  INFO 67476 --- [   scheduling-1] c.m.p.s.j.t.UserBirthdayBasedPushTask    : Task running at = 2020-03-07T21:21:45.008

可以看出每 5s 执行一次,是分布在两个实例中,同一时刻只有一个任务在执行,这与我们的预期是一致。数据库表记录(有两个定时任务的情况下):

定时发送营销短信

初步框架构建完成,现在填充据会员生日信息推送营销短信的功能。

有小伙伴一听说定时任务,一定要找服务压力小的时间段来处理,索性放到凌晨。但凌晨让用户收到营销短信,真的好吗?所以还是要考虑产品的用户体验,不能盲目定时。

前面服务调用章节我们已经学会了服务间的调用 ,这次是定时任务项目要调用会员服务里的方法,依旧采用 Feign 的方式进行。编写 MemberServiceClient 接口,与会员服务中的会员请求响应类保持一致

java 复制代码
@FeignClient(value = "member-service", fallback = MemberServiceFallback.class)
public interface MemberServiceClient {

    @RequestMapping(value = "/member/list", method = RequestMethod.POST)
    public CommonResult<List<Member>> list() throws BusinessException;

    @RequestMapping(value = "/member/getMember", method = RequestMethod.POST)
    public CommonResult<Member> getMemberInfo(@RequestParam(value = "memberId") String memberId);

}

任务执行类编写业务逻辑,这里用到了 Member 实体,但这个实体是维护在会员服务中的,未对外公开。*对于一些公用类,可以抽取到一个公共项目中,供各项目间相互引用,而不是维护多份。*

java 复制代码
@Component
@Slf4j
public class UserBirthdayBasedPushTask {

    @Autowired
    MemberServiceClient memberService;

    @Scheduled(cron = " 0/5 * * * * ?")
    @SchedulerLock(name = "scheduledTaskName")
    public void scheduledTask() {
        CommonResult<List<Member>> members;
        try {
            members = memberService.list();
            List<Member> resp = members.getRespData();

            DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
            LocalDateTime time = LocalDateTime.now();
            String curTime = df.format(time);
            for (Member one : resp) {
        //当天生日的推送营销短信
                if (curTime.equals(one.getBirth())) {
                    log.info(" send sms to  " + one.getPhone() );
                }
            }
        } catch (BusinessException e) {
            log.error("catch exception " + e.getMessage());
        }

        log.info("Task running at = "  + LocalDateTime.now());
    }
}

启动会员服务、定时任务两个项目,测试业务逻辑的是否运行正常。定时任务执行时,发现出现异常:

shell 复制代码
Caused by: org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize instance of `com.mall.parking.common.bean.CommonResult` out of START_ARRAY token; nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize instance of `com.mall.parking.common.bean.CommonResult` out of START_ARRAY token at [Source: (PushbackInputStream); line: 1, column: 1]

定位原因: CommonResult 对象中含有 Member List 对象集合,JSON 对象解析时的结构应该为 {},但返回值是[],肯定会解析异常。需要将 Feign 接口变更为原始的 JSON 字符串形式。

java 复制代码
//MemberServiceClient 接口方法变更为此
@RequestMapping(value = "/member/list", method = RequestMethod.POST)
public String list() throws BusinessException;

任务执行类变更操作方式,如下

java 复制代码
    @Scheduled(cron = " 0/5 * * * * ?")
    @SchedulerLock(name = "scheduledTaskName")
    public void scheduledTask() {
        try {
            String members = memberService.list();
            List<Member> array  = JSONArray.parseArray(members, Member.class);

            DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
            LocalDateTime time = LocalDateTime.now();
            String curTime = df.format(time);
            for (Member one : array) {
                if (curTime.equals(one.getBirth())) {
                    log.info(" send sms to  " + one.getPhone() );
                }
            }
        } catch (BusinessException e) {
            log.error("catch exception " + e.getMessage());
        }

        log.info("Task running at = "  + LocalDateTime.now());
    }

再重新启动两个项目,测试任务已经可以正常执行。如果你的项目中还需要更多的定时任务的话,参照这种方式编写相应代码即可。

本章节从定时任务入手,谈了几个定时任务的解决方案,接着引入分布式定时任务来完成我们的短信营销任务,完整的实施一次分布式定时任务。给你留下个动手题目吧:如果使用 elastic-job 组件,又当如何实现这个分布式定时任务呢?

12 同一套服务如何应对不同终端的需求------服务适配

经过前几个章节的实践,会员已可以绑定手机号,更新个人信息,绑定个人车辆信息,开通月卡,签到等功能,下面从客户端查看自己的数据入手,再聊聊服务调用的问题。

简单处理

我们已经将用户数据进行垂直拆分,分布在不同数据库中,当客户端数据展现时,就需要分别调用不同服务的 API,由前端将数据重新组装展现在用户端。

会员个人信息、车辆信息、月卡信息维护在会员库中,积分信息维护在积分库中。如果想一个页面同时展现这两块的数据,就必须由客户端发起两次接口调用,才能完整地将数据调用到,如下图所示:

这种方式相当将主动权交给前端,由前端完成数据整理,后端仅提供细粒度的服务。微服务架构在增加业务灵活性的同时,也让前端的调用变得复杂起来,有两个问题暴露得很突出:

  1. 前端发起多次接口请求,网络开销增大,极端情况下不利于用户体验
  2. 前端开发工作量增加

服务聚合

前面数据调用流程暴露出来的问题,在功能复杂、服务拆分较细时,问题就会被放大,影响产品的使用。这里就需要优化一下调用流程,我们在架构层面稍加调整,在客户端与微服务层中间增加一个适配层,目的也很简单,客户端仅发起一次请求,调用适配层服务,适配层服务中将多个子服务进行聚合,各子库里的数据按照业务规则重新组装成前端需要的数据,再返还给前端时,前端仅做展现。于是调用链就变成下图的模样:

原本客户端发起的两次请求(实际情况可能更多,据数据分散情况而定),就减少到一次请求。服务中也可以提供不同粒度的 API,极细粒度的 API,也在在细粒度 API 的基础上,提供初步的聚合接口。针对不同的数据,再在适配层在更高层面做一次数据聚合。

服务适配

服务聚会中已经初步将调用流程做了优化,但依旧有不足之处。移动互联网时代背景下,终端的形式越来越丰富,微信公众号、小程序、原生应用,再加上 Pad 端、桌面端等,面对不同的客户端,单一适配层在应对多个终端的不同需求时,难免顾此失彼,在同一个适配层协调难度极大。当终端的需求变更时,面对不同终端的 API 接口都需要做出变更,开发、测试、运维成本还是很高的。需要再进一步将结构作出变更,优化后如下图:

针对每个客户端,后端都构建一个适配层与之相对应,当一方需求变更时,仅需要对应的适配层修改即可,也无须变更更底层的后端服务。

如果客户端需要调用细粒度的服务,也可以直接调用底层微服务,并不是非要经过适配层服务,这不是绝对的。

BFF 架构

针对上面提供的服务聚合与服务适配的问题,业界早有种提法,称之为BFF 架构,全称为 Backend For Frontend,意为服务于前端的后端,本层中可以针对前端的不同需求,在不变更后端基础服务的基础上,进行服务的调整,具有语言无关性,可以采用 Java、Node.js、PHP 或者 Go 等其它语言来实现 BFF 层,至于这一层由前端开发人员维护还是后台开发人员维护,业界没有统一约定,但更倾向于由前端代码构建,因为 BFF 层与前端贴合更紧密。

项目实战

由于我们是基于 Java 平台进行开发,所以这个适配层,依然选择 Java,当然如果还有擅长的语言,如 Node.js 也可以使用。

新建两个适配层服务项目,parking-bff-minprogram-serv 和 parking-bff-native-serv 项目,分别应对小程序端和原生应用端。将这两个基本的功能添加完整,依照之前的项目配置,使其可以正常应用,比如提供接口管理界面、服务调用、断路器配置、服务注册与发现等等。

小程序与原生应用在获取会员信息时有个差别------小程序不需要车辆信息,而原生应用中需要展示车辆信息。

编写会员、积分的调用接口 feignClient 类:

java 复制代码
@FeignClient(value = "member-service", fallback = MemberServiceFallback.class)
public interface MemberServiceClient {

    @RequestMapping(value = "/member/getMember", method = RequestMethod.POST)
    public CommonResult<Member> getMemberInfo(@RequestParam(value = "memberId") String memberId);

  //parking-bff-minprogram-serv 适配层没有此接口
    @RequestMapping(value = "/vehicle/get", method = RequestMethod.POST)
    public CommonResult<Vehicle> getVehicle(@RequestParam(value = "memberId") String memberId);
}

@FeignClient(value = "card-service", fallback = MemberCardServiceFallback.class)
public interface MemberCardClient {

    @RequestMapping(value = "/card/get", method = RequestMethod.POST)
    public CommonResult<MemberCard> get(@RequestParam(value = "memberId") String memberId) throws BusinessException;

}

编写业务逻辑处理类:

java 复制代码
@RestController
@RequestMapping("bff/nativeapp/member")
public class APIMemberController {

    @Autowired
    MemberServiceClient memberServiceClient;

    @Autowired
    MemberCardClient memberCardClient;

    @PostMapping("/get")
    public CommonResult<MemberInfoVO> getMemberInfo(String memberId) throws BusinessException {
        CommonResult<MemberInfoVO> commonResult = new CommonResult<>();

        // service aggregation
        CommonResult<Member> member = memberServiceClient.getMemberInfo(memberId);
        CommonResult<Vehicle> vehicle = memberServiceClient.getVehicle(memberId);
        CommonResult<MemberCard> card = memberCardClient.get(memberId);

        MemberInfoVO vo = new MemberInfoVO();
        if (null != member && null != member.getRespData()) {
            vo.setId(member.getRespData().getId());
            vo.setPhone(member.getRespData().getPhone());
            vo.setFullName(member.getRespData().getFullName());
            vo.setBirth(member.getRespData().getBirth());
        }

        if (null != card && null != card.getRespData()) {
            vo.setCurQty(card.getRespData().getCurQty());
        }
        //parking-bff-minprogram-serv 适配层没有此数据聚合
        if (null != vehicle && null != vehicle.getRespData()) {
            vo.setPlateNo(vehicle.getRespData().getPlateNo());
        }
        commonResult.setRespData(vo);
        return commonResult;
    }
}

从代码中可以看出,原先需要由客户端发起调用两次的接口,直接由适配层中完成调用,聚合后一次性返回给客户端,减少了一次交互。针对不同终端,数据响应也不一致,降低数据传输成本和部分数据敏感性暴露的可能。

至此,通过引入 BFF 适配层,又将我们的架构近一步优化,降低了前端调用的开发复杂度以及网络开销,除了服务聚合与服务适配之外,你还能想到 BFF 层有什么其它功能吗?

13 采用消息驱动方式处理扣费通知------集成消息中间件

缓存与队列,是应对互联网高并发高负载环境的常见策略,缓存极大地将数据读写,队列有效地将压力进行削峰平谷,降低系统的负载。实现队列较好的解决方案就是利用消息中间件,但消息中间件绝不止队列这一个特性,还可以应用于异步解耦、消息驱动开发等功能,本章节就带你走进微服务下的消息驱动开发。

消息中间件产品

消息中间件产品不可谓不多,常见的有 Apache ActiveMQ、RabbitMQ、ZeroMQ、Kafka、Apache RocketMQ 等等,还有很多,具体如何选型,网络中存在大量的文章介绍(这里有一篇官方的文档,与 ActiveMQ、Kafka 的比较,http://rocketmq.apache.org/docs/motivation/),这里不展开讨论。

Message-oriented middleware (MOM) is software or hardware infrastructure supporting sending and receiving messages between distributed systems.

上面是来源于 Wikipedia 对消息中间件的定义,场景很明确------分布式系统,可能是软件或者是硬件,通过发送、接受消息来进行异步解耦,通常情况下有三块组成:消息的生产者、中间服务和消息的消费者。

本案例主要基于 Spring Cloud Alibaba 项目展开,RocketMQ 作为项目集的一部分,在阿里产品线上优越的性能表现,使得越为越多的项目进行技术选型时选择它,本次消息中件间也是采用 RocketMQ,下面从弄清 RocketMQ 的基本原理开始吧。

RocketMQ 是什么

RocketMQ 是阿里开源的分布式消息中间件,纯 Java 实现;集群和 HA 实现相对简单;在发生宕机和其它故障时消息丢失率更低。阿里很多产品线都在使用,经受住了很多大压力下的稳定运行。目前交由 Apache 开源社区,社区活跃度更高。官网地址:http://rocketmq.apache.org/。

核心模块有以几个:

  • Broker 是 RocketMQ 的核心模块,负责接收并存储消息
  • NameServer 可以看作是 RocketMQ 的注册中心,它管理两部分数据:集群的 Topic-Queue 的路由配置;Broker 的实时配置信息。所以,必须保证 broker/nameServer 可用,再能进行消息的生产、消费与传递。
  • Producer 与 product group 归属生产者部分,就是产生消息的一端。
  • Consumer 与 consumer group 归属消费者部分,负责消费消息的一端。
  • Topic/message/queue,主要用于承载消息内容。

(RocketMQ 架构图,来源于官网,图中所示均是以 Cluster 形态出现)

RocketMQ 配置安装

准备好编译后的二进制安装包,也即是常见的绿色解压版。

appledeMacBook-Air:bin apple$ wget http://mirror.bit.edu.cn/apache/rocketmq/4.6.0/rocketmq-all-4.6.0-bin-release.zip

appledeMacBook-Air:software apple$unzip rocketmq-all-4.6.0-bin-release.zip

appledeMacBook-Air:software apple$cd rocketmq-all-4.6.0-bin-release/bin

appledeMacBook-Air:bin apple$ nohup ./mqnamesrv &

appledeMacBook-Air:bin apple$ nohup ./mqbroker -n localhost:9876 &

另外,必须设置好 NAMESRV_ADDR 地址,否则无法正常使用,也可写入 profile 文件中,也可用直接采用命令行的方式:

bash 复制代码
export NAMESRV_ADDR=localhost:9876

关闭的话,先关闭 broker server,再关闭 namesrv。

bash 复制代码
sh bin/mqshutdown broker
The mqbroker(12465) is running...
Send shutdown request to mqbroker(12465) OK
sh bin/mqshutdown namesrv
The mqnamesrv(12456) is running...
Send shutdown request to mqnamesrv(12456) OK
测试是否安装成功

启动两个终端,消息生产端输入命令行:

bash 复制代码
#sh bin/tools.sh org.apache.rocketmq.example.quickstart.Producer
 SendResult [sendStatus=SEND_OK, msgId= ...
#下方显示循环写入消息,待消费者消费

在另个终端,输入消费者命令行:

bash 复制代码
#sh bin/tools.sh org.apache.rocketmq.example.quickstart.Consumer
 ConsumeMessageThread_%d Receive New Messages: [MessageExt...
#下文直接打印出生产端写入的消息

服务集成 RocketMQ

基于 Spring Cloud 项目集成 RocketMQ 时,需要用到 Spring Cloud Stream 子项目,使用时同样需要注意子项目与主项目的版本对应问题。项目中三个关键概念:

  • Destination Binders:与外部组件集成的组件,这里的组件是指 Kafka 或 RabbitMQ等
  • Destination Bindings:外部消息传递系统和应用程序之间的桥梁,在下图中的灰柱位置
  • Message:消息实体,生产者或消费者基于这个数据实体与消息中间件进行交互通信

(图示来源于官方文档 spring-cloud-stream-overview-introducing

下面通过实践来加深以上图的理解。

消费者端集成

parking-message 模块作为消息消费者端,在 pom.xml 中引入 jar(这里未配置 version 相信你已知道原因了):

xml 复制代码
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-stream-rocketmq</artifactId>
</dependency>

相应的配置文件 application.properties 中增加配置项:

properties 复制代码
#rocketmq config
spring.cloud.stream.rocketmq.binder.name-server=127.0.0.1:9876
#下面配置中的名字为 input 的 binding 要与代码中的 Sink 中的名称保持一致
spring.cloud.stream.bindings.input.destination=park-pay-topic
spring.cloud.stream.bindings.input.content-type=text/plain
spring.cloud.stream.bindings.input.group=park-pay-group
#是否同步消费消息模式,默认是 false
spring.cloud.stream.rocketmq.bindings.input.consumer.orderly=true

此处采用默认的消息消费通道 input。在启动类中增加注解@EnableBinding({Sink.class}),启动时连接到消息代理组件。什么是 Sink?项目内置的简单消息通道定义,Sink 代表消息的去向。生产者端会用到 Source,代表消息的来源。

编写消费类,增加 @StreamListener 注解,以使其接收流处理事件,源源不断的处理接受到的消息:

java 复制代码
@Service
@Slf4j
public class ConsumerReceive {

    @StreamListener(Sink.INPUT)
    public void receiveInput(String json) throws BusinessException{
    //仅做测试使用,正式应用可集成相应消息推送接口,比如极光、微信、短信等
        log.info("Receive input msg = " +json +" by RocketMQ...");

    }
}
生产者端集成

parking-charging 模块中,客户车辆出场时,不管是月卡用户支付或是非月卡用户支持,支付后需要发送消息给客户,提示扣费信息。在模块 pom.xml 文件中以 starter 方式引入 jar:

xml 复制代码
<!-- rocketmq -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-stream-rocketmq</artifactId>
</dependency>

application.properties:

properties 复制代码
#rocketmq config
spring.cloud.stream.rocketmq.binder.name-server=127.0.0.1:9876
#下面配置中的名称为output的binding要与代码中的Source中的名称保持一致
spring.cloud.stream.rocketmq.bindings.output.producer.group=park-pay-group-user-ouput
spring.cloud.stream.rocketmq.bindings.output.producer.sync=true

spring.cloud.stream.bindings.output.destination=park-pay-topic
spring.cloud.stream.bindings.output.content-type=application/json

启动类增加 @EnableBinding({Source.class}) 注解,注意,此处绑定关键标识是 Source,与 消费端的 Sink 形成呼应。

为什么消费者是 Sink/input,而生产者是 Source/output,怎么看有点矛盾呢?我们这样来理解:生产者是源头,是消息输出;消费者接受外界输入,是 input。

编写消息发送方法:

java 复制代码
@Autowired
Source source;

@PostMapping("/sendTestMsg")
public void sendTestMsg() {
    Message message = new Message();
    message.setMcontent("这是第一个消息测试.");
    message.setMtype("支付消息");
    source.output().send(MessageBuilder.withPayload(JSONObject.toJSONString(message)).build());
}

分别启动 parking-charging、parking-message 两个模块,调用发送消息测试方法,正常情况下输出日志:

复制代码
2020-01-07 20:37:42.311  INFO 93602 --- [MessageThread_1] c.m.parking.message.mq.ConsumerReceive   : Receive input msg = {"mcontent":"这是第一个消息测试.","mtype":"支付消息"} by RocketMQ...
2020-01-07 20:37:42.315  INFO 93602 --- [MessageThread_1] s.b.r.c.RocketMQListenerBindingContainer : consume C0A800696DA018B4AAC223534ED40000 cost: 35 ms

这里仅采用了默认的 Sink 和 Source 接口,当项目中使用的通道更多时,可以自定义自己的 Sink 和 Source 接口,只要保持 Sink 和 Source 的编写规则,在项目中替换掉默认的加载类就可以正常使用。

java 复制代码
//自定义 Sink 通道
public interface MsgSink {

    /**
     * Input channel name.
     */
    String INPUT1 = "input1";

    /**
     * @return input channel.订阅一个消息
     */
    @Input(MsgSink.INPUT1)
    SubscribableChannel myInput();
}
//自定义 Source 通道
public interface MsgSource {

    /**
     * Name of the output channel.
     */
    String OUTPUT = "output1";

    /**
     * @return output channel
     */
    @Output(MsgSource.OUTPUT)
    MessageChannel output1();
}

Spring Cloud Stream 项目集成了很多消息系统组件,有兴趣的小伙伴可以尝试下其它的 消息系统,看与 RocketMQ 有多少区别。以上我们完成通过中间修的完成了消息驱动开发的一个示例,将系统异步解耦的同时,使系统更关注于自己的业务逻辑。比如 parking-message 项目集中精力处理与外界消息的推送,比如向不同终端推送微信消息、短信、邮件、App 推送等。

留个思考题:

微服务间的服务调用与本章节提到的消息驱动,哪一个将系统间的耦合性降得更低呢?实施起来哪个更方便呢?

14 Spring Cloud 与 Dubbo 冲突吗------强强联合

微服务开发选型,到底是基于 Dubbo 还是 Spring Cloud,相信不少开发的小伙伴都有拿这两个项目作过作比较的经历。本章节就带你走近这两个项目,二者究竟是竞争发展还是融合共赢。

项目发展简介

我们还是先来看看 Dubbo 的发展历史:

  1. 2012 年由阿里开源,在很短时间内,被许多互联网公司所采用。
  2. 由于公司策略发生变更,2014 年 10 月项目停止维护,版本静止于 dubbo-2.4.11。处于非维护期间,当当网基于分支重新开源了 DubboX 框架。
  3. 2017 年 9 月,阿里宣布重启 Dubbo 项目,重新发布新版本 dubbo-2.5.4,并将其作为社区开源产品长期推进下去,此后版本迭代开始重新发力。
  4. 2018 年 2 月,阿里将 Dubbo 捐献给 Apache 基金会孵化。
  5. 2019 年 5 月,Apache Dubbo 正式升级为顶级项目。

Dubbo 定位于高性能、轻量级的开源 Java RPC 框架,随着社区的不断丰富,Dubbo 生态越来越繁荣。

官方为快速开发者上手 Dubbo 应用,仿照 start.spring.io,推出快速生成基于 Spring Boot 的 Dubbo 项目的网站:http://start.dubbo.io/。更详细的文档,可到官网查看。

Spring Cloud 的历史很短,Spring Cloud 源于 Spring,来梳理下 Spring 的发展情况:

  1. 最早可以追溯到 2002 年,由 Rod Johnson 撰写一本名为"Expoert One-on-One J2EE "设计和开发的书。
  2. 2003 年 2 月左右,Rod,Juergen 和 Yann 于 开始合作开发项目,Yann 为新框架创造了"Spring"的名字。Yann Caroff 在早期离开了团队,Rod Johnson 在 2012 年离开,Juergen Hoeller 仍然是 Spring 开发团队的积极成员。
  3. 2007 年 11 月,在 Rod 领导下管理 Interface21 项目更名为 SpringSource。
  4. 2007 年,SpringSource 从基准资本获得了 A 轮融资(1000 万美元),SpringSource 在此期间收购了多家公司,如 Hyperic,G2One 等。
  5. 2009 年 8 月,SpringSource 以 4.2 亿美元被 VMWare 收购。
  6. 2012 年 7 月,Rod Johnson 离开了团队。
  7. 2013 年 4 月,VMware 和 EMC 通过 GE 投资创建了一家名为 Pivotal 的合资企业。所有的 Spring 应用项目都转移到了 Pivotal。
  8. 2014 年 Pivotal 发布了 Spring Boot。
  9. 2015 年,戴尔又并购了 EMC。
  10. 2015 年 Pivotal 发布了 Spring Cloud。
  11. 2018 年 Pivotal 公司在纽约上市。

从漫长的发展历史中,可见 Spring 的发展也是一波三折。事实上,做 Java 开发基本绕不开 Spring,Spring 社区对 Java 的发展有着极大的影响力,而 Spring Cloud 则是基于 Spring、 Spring Boot 生态提供了一整套开箱即用的全家桶式的解决方案,极大的方便了开发者快速上手微服务开发,背后的商业公司更是为其提供了强大的支撑,同时不少核心项目组件能看到 Netflix OSS 的身影,如 Eureka 等,均在 Netflix 线上的分布式生产环境中已经得到很好的技术验证,无形中增强了信用背书。

Dubbo 在国内有较大的市场影响力,但国际市场上 Spring Cloud 的占有率要比 Dubbo 大,毕竟原生的英文环境及 Spring 社区的庇荫都是生态繁荣的优势。随着 Dubbo 正式成为 Apache 顶级项目后,相信未来在国际市场上的采用度会越来越高。

技术选型困扰

二者的交集是发现在 2015 年左右,一方面 Dubbo 在国内应用广泛,以简单易上手、高性能著称,遗憾之处在于社区几乎停滞。而此时 Spring Cloud 以全新姿态面世,基于 Spring Boot 的约定优于配置的原则,在 Java 轻量级开发中迅速传播开来,但组件种类多、资料少、学习曲线高也是不争的事实。

早期大家做技术选型时,经常会将二者拿出来作比较,典型的可参照:《微服务架构的基础框架选择:Spring Cloud 还是 Dubbo?》一文。2016 年公司在做技术选型时,我同样也面临这个问题,鉴于当时的业务需求及团队的技术储备能力,最终还是选择了处在非维护期的 Dubbo,后期无法满足需求时再考虑重构。

Spring Cloud 早期的服务注册中心是基于 Eureka,Dubbo 采用的注册中心是 ZooKeeper,一套服务存在两个服务管理方案,复杂度相当高,又各自在各自的领域内,有各自的解决方案,要整合起来,也非易事。

近两年来 Spring Cloud Alibaba 的出现,这种二选一的局面得到了极大的改善。一方面,可以替代原项目中一些不再维护的项目功能。另一方面,可以将阿里技术生态与 Spring Cloud 生态融合起来。二者都可以采用 Nacos 作为服务注册中心,同时也完美替代 Spring Cloud Config 提供了更简洁直观的配置管理,降低了复杂度。另外,也为 Spring Cloud 生态中也引入了 RPC 解决方案------Dubbo,与 REST 方式形成互补。

二者融合实战

现在我们就通过一个业务功能------会员通过积分兑换来洗车券去洗车,将两个项目融合在一起。

新增 parking-carwash 父项目

此模块需要完成对外提供 RPC 接口的功能,代码结构如下

下属两个子项目模块,api 项目只是简单的 Java 项目,构建成 jar 包供外部项目依赖调用,serv 项目基于 Spring Boot 提供实际业务服务,以 jar 的形式独立运行。

parking-carwash-serv 服务提供者

在 api 模块中编写接口,同时将对应的实体放在这里,以便被依赖时正常使用。参照之前的方式配置基本的基础组件,再引入 Dubbo 相关的 jar,配置如下:

xml 复制代码
<!-- 必须包含 spring-boot-starter-actuator 包,不然启动会报错。 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-actuator</artifactId>
</dependency>

<!-- Dubbo Spring Cloud Starter -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-dubbo</artifactId>
</dependency>

为体验下 yml 配置文件的应用情况,本项目中引入 bootstrap.yml 文件,完全可以采用一个 application 配置文件:

yaml 复制代码
spring:
  application:
    name: carwash-service
  main:
    allow-bean-definition-overriding: true
  cloud:
    nacos:
      discovery:
        enabled: true
        register-enabled: true
        server-addr: 127.0.0.1:8848

application.properties 中配置 Dubbo:

properties 复制代码
# dubbo config
dubbo.protocols.dubbo.name=dubbo
dubbo.protocols.dubbo.port=-1
dubbo.scan.base-packages=com.mall.parking.carwash.serv.service
dubbo.registry.address=spring-cloud://127.0.0.1
dubbo.registry.register=true
dubbo.application.qos.enable=false

#此配置项为了防止 nacos 大量的 naming 日志输出而配置
logging.level.com.alibaba.nacos.client.naming=error

编写接口及实现类:

java 复制代码
public interface WashService {
    int wash(String json) throws BusinessException;
}

@Service(protocol = "dubbo")
@Slf4j
public class WashServiceImpl implements WashService {

    @Autowired
    CarWashMapper carWashMapper;

    @Override
    public int wash(String json) throws BusinessException {
        CarWash carWash = JSONObject.parseObject(json, CarWash.class);
        int rtn = carWashMapper.insertSelective(carWash);
        log.info("car wash data = " + json + "> write suc...");

        return rtn;
    }

}

注意:@Service 注解不再使用 Spring 的,而是采用 Dubbo 提供的注解 org.apache.dubbo.config.annotation.Service,注释中同时提供了多种属性值,用于配置接口的多种特性,比如服务分组、服务版本、服务注册是否延迟、服务重试次数等等,依实际使用情况而定。

Application 启动类,与一般 Spring Cloud 的启动类无异。启动后,在 Nacos 的服务列表中可以看到本模块的服务已经注册成功。

parking-member 服务消费者

在前期构建完成的 parking-member 项目中引入 Dubbo 的 jar 和 api 接口 jar。

xml 复制代码
<!-- Dubbo Spring Cloud Starter -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-dubbo</artifactId>
</dependency>
<dependency>
    <groupId>com.mall.parking.root</groupId>
    <artifactId>parking-carwash-api</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

application.properties 配置:

properties 复制代码
#dubbo config
dubbo.registry.address=nacos://localhost:8848
dubbo.application.qos.enable=false
dubbo.cloud.subscribed-services=carwash-service
spring.main.allow-bean-definition-overriding=true
#不检测服务提供者是否在线,生产环境建议开启
dubbo.consumer.check=false

#more naming logs output,config this to avoid more log output
logging.level.com.alibaba.nacos.client.naming=error

编写服务调用类:

java 复制代码
@Reference
WashService washService;//像调用本地 jar 一样,调用服务

/**
     * {"plateNo":"湘 AG7890","ticketCode":"Ts0999"}
     * 
     * @param json
     * @return
     * @throws BusinessException
     */
@PostMapping("/wash")
public CommonResult<Integer> wash(String json) throws BusinessException {
    log.debug("add vehicle = " + json);
    CommonResult<Integer> result = new CommonResult<>();

    int rtn = washService.wash(json);
    result.setRespData(rtn);
    return result;
}
测试

服务提供者启动后,再启动会员模块服务,使用 Postman,访问 vehicle/wash 方法,可以看到服务正常调用,数据写入 park-carwash 数据库。

至此,我们将 Dubbo 与 Spring Cloud 两大项目完美整合到一个项目中,项目中既可以用到 RPC 框架的高效能,也可以享受到全家桶的便利性。

留下一题思考题:

  • 有两种引入 Dubbo 的 starter 方式,spring-cloud-starter-dubbo 和 dubbo-spring-boot-starter,这两种方式有什么区别呢?

15 破解服务中共性问题的繁琐处理方式------接入 API 网关

由于服务粒度的不同以及数据包装因端而异的差异需求,我们在之前章节中引入了 BFF 层,调用端可以直接调用 BFF 层,由 BFF 层再将请求分发至不同微服务,进行数据组装。由于很多子服务都需要用户验证、权限验证、流量控制等,真的要在每个子服务中重复编写用户验证的逻辑吗?本章节就带你走近网关,在网关层统一处理这些共性需求。

为什么引入网关

如果没有网关的情况下,服务调用面临的几个直接问题:

  1. 每个服务都需要独立的认证,增加不必要的重复度
  2. 客户端直接与服务对接,后端服务一旦变动,前端也要跟着变动,独立性缺失
  3. 将后端服务直接暴露在外,服务的安全性保障是一个挑战
  4. 某些公共的操作,如日志记录等,需要在每个子服务都实现一次,造成不必要的重复劳动

现有系统的调用结构如下图所示:

直接由前端发起调用,服务间的调用可以 由服务注册中心调配,但前端调用起来就没这么简单了,特别是后端服务以多实例的形态出现时。由于各个子服务都有各自的服务名、端口号等,加之某些共性的东西(如鉴权、日志、服务控制等)重复在各子模块实现,造成不必要的成本浪费。此时,就亟需一个网关,将所有子服务包装后,对外统一提供服务,并在网关层针对所有共性的功能作统一处理,大大提高服务的可维护性、健壮性。

引入网关后,请求的调用结构演变成如下图:

可以看到明显的变化:由网关层进行统一的请求路由,将前端调用的选择权解放出来;后端服务隐藏起来,对外只能看到网关的地址,安全性大大提升;一些共性操作,直接由网关层实现,具体服务实现不再承担这部分工作,更加专心于业务实现。

本文带你将 spring-cloud-gateway 组件引入项目中,有同学会问,为什么不用 Zuul 呢?答案是由于组件发展的一些原因,Zuul 进入了维护期,为保证组件的完整性,Spring 官方团队开发出 Gateway 以替代 Zuul 来实现网关功能。

建立 Gateway 服务

引入 jar 时,注意 Spring Cloud Gateway 是基于 Netty 和 WebFlux 开发,所以不需要相关的 Web Server 依赖,如 Tomcat 等,WebFlux 与 spring-boot-starter-web 是冲突的,需要将这两项排除,否则无法启动。

xml 复制代码
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    <version>0.2.2.RELEASE</version>
</dependency>

启动类与正常业务模块无异,在 application.yml 配置文件中进行初步配置:

yaml 复制代码
server: 
  port: 10091

management: 
  endpoints: 
    web: 
      exposure: 
        include: '*'

#nacos config
spring: 
  application: 
    name: gateway-service
  cloud: 
    nacos: 
      discovery: 
        register-enabled: true
        server-addr: 127.0.0.1:8848
#      config: 
#        server-addr: 127.0.0.1:8848
    gateway: 
      discovery:
        locator:
          enabled: false  #gateway 开启服务注册和发现的功能,并且自动根据服务发现为每一个服务创建了一个 router,这个 router 将以服务名开头的请求路径转发到对应的服务。
          lowerCaseServiceId: true   #是将请求路径上的服务名配置为小写
          filters:
            - StripPrefix=1
      routes: 
      #一个服务中的 id、uri、predicates 是必输项
      #member 子服务
      - id: member-service
        uri: lb://member-service
        predicates: 
        - Path= /member/**
        filters: 
        - StripPrefix=1
      #card 子服务
      - id: card-service
        uri: lb://card-service
        predicates: 
        - Path=/card/**
        filters:
        - StripPrefix=1
      #resource 子服务
      - id: resource-service
        uri: lb://resource-service
        predicates: 
        - Path=/resources/**
        filters:
        - StripPrefix=1
      #计费子服务
      - id: charging-service
        uri: lb://charging-service
        predicates: 
        - Path=/charging/**
        filters: 
        - StripPrefix=1
      #finance 子服务
      - id: finance-service
        uri: lb://finance-service
        predicates: 
        - Path=/finance/**
        filters: 
        - StripPrefix=1

routes 配置项是具体的服务路由规则配置,各服务以数组形式配置。id 用于服务间的区分,uri 则对应直接的调用服务,lb 表示以负载的形式访问服务,lb 后面配置的是 Nacos 中的服务名。predicates 用于匹配请求,无须再用服务的形式访问。

到此完成 Gateway 网关服务的简单路由功能已完成,前端直接访问网关调用对应服务,不必再关心子服务的服务名、服务端口等情况。

熔断降级

有服务调用章节,我们通过 Hystrix 实现了服务降级,在网关层面是不是可以做一个统一配置呢?答案是肯定的,下面我们在 Gateway 模块中引入 Hystrix 来进行服务设置,当服务超时或超过指定配置时,直接快速返回准备好的异常方法,快速失败,实现服务的熔断操作。

引入相关的 jar 包:

xml 复制代码
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

配置文件中设置熔断超时时间设置:

yaml 复制代码
#timeout time config,默认时间为 1000ms,
hystrix: 
  command: 
    default: 
      execution: 
        isolation: 
          thread: 
            timeoutInMilliseconds: 2000

编写异常响应类,此类需要配置在子服务的失败调用位置。

java 复制代码
@RestController
@RequestMapping("error")
@Slf4j
public class FallbackController {

    @RequestMapping("/fallback")
    public CommonResult<String> fallback() {
        CommonResult<String> errorResult = new CommonResult<>("Invoke failed.");
        log.error("Invoke service failed...");
        return errorResult;
    }
}

      #card 子服务
      - id: card-service
        uri: lb://card-service
        predicates: 
        - Path=/card/**
        filters:
        - StripPrefix=1
        #配置快速熔断失败调用
        - name: Hystrix
          args: 
            name: fallbackcmd
            fallbackUri: forward:/error/fallback

若服务暂时不可用,发起重试后又能返回正常,可以通过设置重试次数,来确保服务的可用性。

yaml 复制代码
#card 子服务
- id: card-service
  uri: lb://card-service
  predicates: 
  - Path=/card/**
  filters:
  - StripPrefix=1
  - name: Hystrix
    args: 
      name: fallbackcmd
      fallbackUri: forward:/error/fallback
  - name: Retry
    args: 
        #重试 3 次,加上初次访问,正确执行应当是 4 次访问
       retries: 3
       statuses: 
       - OK
       methods: 
       - GET
       - POST
       #异常配置,与代码中抛出的异常保持一致
       exceptions: 
       - com.mall.parking.common.exception.BusinessException

如何测试呢?可以代码中增加异常抛出,来测试请求是否重试 3 次,前端调用时,通过网关访问此服务调用,可以发现被调用次数是 4 次。

java 复制代码
/* 这里抛出异常是为了测试 spring-cloud-gateway 的 retry 机制是否正常运行
	* if (StringUtils.isEmpty("")) {
     throw new BusinessException("test retry function");
    }*/

服务限流

为什么要限流,当服务调用压力突然增大时,对系统的冲击是很大的,为保证系统的可用性,做一些限流措施很有必要。

常见的限流算法有令牌桶、漏桶等,Gateway 组件内部默认实现了 Redis + Lua 进行限流,可以通过自定义的方式来指定是根据 IP、用户或是 URI 来进行限流,下面我们来一控究竟。

Spring Cloud Gateway 默认提供的 RedisRateLimter 的核心逻辑为判断是否取到令牌的实现,通过调用 META-INF/scripts/request_rate_limiter.lua 脚本实现基于令牌桶算法限流,我们来看看如何借助这个功能来达到我们的目的。

引入相应 jar 包的支持:

xml 复制代码
<!--基于 reactive stream 的 redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>

配置基于 IP 进行限流,比如在商场兑换优惠券时,在固定时间内,仅有固定数量的商场优惠券来应对突然间的大量请求,很容易出现高峰交易的情况,导致服务卡死不可用。

yaml 复制代码
        - name: RequestRateLimiter
          args: 
            redis-rate-limiter.replenishRate: 3 #允许用户每秒处理多少个请求
            redis-rate-limiter.burstCapacity: 5 #令牌桶的容量,允许在一秒钟内完成的最大请求数
            key-resolver: "#{@remoteAddrKeyResolver}" #SPEL 表达式去的对应的 bean

上文的 KeyResolver 配置项是用来定义按什么规则来限流,比如本次采用 IP 进行限流,编写对应的实现类实现此接口:

java 复制代码
public class AddrKeyResolver implements KeyResolver {

    @Override
    public Mono<String> resolve(ServerWebExchange exchange) {
        return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
    }

}

在启动类进行 @Bean 定义:

java 复制代码
    @Bean
    public AddrKeyResolver addrKeyResolver() {
        return new AddrKeyResolver();
    }

到此,配置完毕,下面来验证配置是否生效。

测评限流是否生效

前期我们采用了 PostMan 组件进行了不少接口测试工作,其实它可以提供并发测试功能,不少用过的小伙伴尚未发现这一功能,这里就带大家一起使用 PostMan 来发起并发测试,操作步骤如下。

1. 建立测试脚本目录

2. 将测试请求放入目录

3. 运行脚本

4. 打开终端 ,进入 Redis 对应的库,输入 monitor 命令,监控 Redis 命令的执行情况。点击上图"Run"按钮,查看 Redis 命令的执行情况。查看 Postman 控制台,可以看到有 3 次已经被忽略执行。

到此,通过原生限流组件可以正常使用,通过 IP 是简单的限流,往往还会有更多个性化的需求,这个时候就需要定制来完成高阶功能。

跨域支持

时下流行的系统部署架构基本是前、后端独立部署,由此而直接引发另一个问题------跨域请求。必须要在网关层支持跨域,不然无法将请求路由到正确的处理节点。这里提供两种方式,一种是代码编写,一种是能过配置文件配置,建议采用配置方式完成。

代码方式
java 复制代码
@Configuration
public class CORSConfiguration {

    @Bean
    public CorsWebFilter corsWebFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(Boolean.TRUE);
        //config.addAllowedMethod("*");
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addExposedHeader("setToken");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
        source.registerCorsConfiguration("/**", config);

        return new CorsWebFilter(source);
    }
}
配置文件配置
yaml 复制代码
spring:
  cloud:
    gateway:
      discovery:
      # 跨域
      globalcors:
        corsConfigurations:
          '[/**]':
            allowedHeaders: "*"
            allowedOrigins: "*"
            # 为保证请求的安全,项目中只支持 get 或 post 请求,其它请求全部屏蔽,以免导致多余的问题
            allowedMethods:
            - POST

本文到此,网关中路由配置、熔断失败、请求限流、请求跨域等常见的共性问题都得到初步的解决,相信随着使用的深入,还有更多高阶的功能等待大家去开发使用。

留一个思考题:

  • 除了 Spring Cloud Gateway 之外,你还知道其它中间件可以实现网关功能吗?不妨去调研一番。

16 服务压力大系统响应慢如何破------网关流量控制

由于服务粒度的不同以及数据包装因端而异的差异需求,我们在之前章节中引入了 BFF 层,调用端可以直接调用 BFF 层,由 BFF 层再将请求分发至不同微服务,进行数据组装。由于很多子服务都需要用户验证、权限验证、流量控制等,真的要在每个子服务中重复编写用户验证的逻辑吗?本章节就带你走近网关,在网关层统一处理这些共性需求。

为什么要引入网关

如果没有网关的情况下,服务调用面临的几个直接问题:

  1. 每个服务都需要独立的认证,增加不必要的重复度
  2. 客户端直接与服务对接,后端服务一旦变动,前端也要跟着变动,独立性缺失
  3. 将后端服务直接暴露在外,服务的安全性保障是一个挑战
  4. 某些公共的操作,如日志记录等,需要在每个子服务都实现一次,造成不必要的重复劳动

现有系统的调用结构如下图所示:

直接由前端发起调用,服务间的调用可以由服务注册中心调配,但前端调用起来就没这么简单了,特别是后端服务以多实例的形态出现时。由于各个子服务都有各自的服务名、端口号等,加之某些共性的东西(如鉴权、日志、服务控制等)重复在各子模块实现,造成不必要的成本浪费。此时,就亟需一个网关,将所有子服务包装后,对外统一提供服务,并在网关层针对所有共性的功能作统一处理,大大提高服务的可维护性、健壮性。

引入网关后,请求的调用结构演变成如下图:

可以看到明显的变化:由网关层进行统一的请求路由,将前端调用的选择权解放出来;后端服务隐藏起来,对外只能看到网关的地址,安全性大大提升;一些共性操作,直接由网关层实现,具体服务实现不再承担这部分工作,更加专心于业务实现。

本文带你将 spring-cloud-gateway 组件引入项目中,有同学会问,为什么不用 Zuul 呢?答案是由于组件发展的一些原因,Zuul 进入了维护期,为保证组件的完整性,Spring 官方团队开发出 Gateway 以替代 Zuul 来实现网关功能。

新增网关服务

引入 jar 时,注意 Spring Cloud Gateway 是基于 Netty 和 WebFlux 开发,所以不需要相关的 Web Server 依赖,如 Tomcat 等,WebFlux 与 spring-boot-starter-web 是冲突的,需要将这两项排除,否则无法启动。

xml 复制代码
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    <version>0.2.2.RELEASE</version>
</dependency>

启动类与正常业务模块无异,在 application.yml 配置文件中进行初步配置:

yaml 复制代码
server: 
  port: 10091

management: 
  endpoints: 
    web: 
      exposure: 
        include: '*'

#nacos config
spring: 
  application: 
    name: gateway-service
  cloud: 
    nacos: 
      discovery: 
        register-enabled: true
        server-addr: 127.0.0.1:8848
#      config: 
#        server-addr: 127.0.0.1:8848
    gateway: 
      discovery:
        locator:
          enabled: false  #gateway 开启服务注册和发现的功能,并且自动根据服务发现为每一个服务创建了一个 router,这个 router 将以服务名开头的请求路径转发到对应的服务。
          lowerCaseServiceId: true   #是将请求路径上的服务名配置为小写
          filters:
            - StripPrefix=1
      routes: 
      #一个服务中的 id、uri、predicates 是必输项
      #member 子服务
      - id: member-service
        uri: lb://member-service
        predicates: 
        - Path= /member/**
        filters: 
        - StripPrefix=1
      #card 子服务
      - id: card-service
        uri: lb://card-service
        predicates: 
        - Path=/card/**
        filters:
        - StripPrefix=1
      #resource 子服务
      - id: resource-service
        uri: lb://resource-service
        predicates: 
        - Path=/resources/**
        filters:
        - StripPrefix=1
      #计费子服务
      - id: charging-service
        uri: lb://charging-service
        predicates: 
        - Path=/charging/**
        filters: 
        - StripPrefix=1
      #finance 子服务
      - id: finance-service
        uri: lb://finance-service
        predicates: 
        - Path=/finance/**
        filters: 
        - StripPrefix=1

routes 配置项是具体的服务路由规则配置,各服务以数组形式配置。id 用于服务间的区分,uri 则对应直接的调用服务,lb 表示以负载的形式访问服务,lb 后面配置的是 Nacos 中的服务名。predicates 用于匹配请求,无须再用服务的形式访问。

到此完成 Gateway 网关服务的简单路由功能已完成,前端直接访问网关调用对应服务,不必再关心子服务的服务名、服务端口等情况。

实现熔断降级

有服务调用章节,我们通过 Hystrix 实现了服务降级,在网关层面是不是可以做一个统一配置呢?答案是肯定的,下面我们在 Gateway 模块中引入 Hystrix 来进行服务设置,当服务超时或超过指定配置时,直接快速返回准备好的异常方法,快速失败,实现服务的熔断操作。

引入相关的 jar 包:

xml 复制代码
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

配置文件中设置熔断超时时间设置:

yaml 复制代码
#timeout time config,默认时间为 1000ms,
hystrix: 
  command: 
    default: 
      execution: 
        isolation: 
          thread: 
            timeoutInMilliseconds: 2000

编写异常响应类,此类需要配置在子服务的失败调用位置。

java 复制代码
@RestController
@RequestMapping("error")
@Slf4j
public class FallbackController {

    @RequestMapping("/fallback")
    public CommonResult<String> fallback() {
        CommonResult<String> errorResult = new CommonResult<>("Invoke failed.");
        log.error("Invoke service failed...");
        return errorResult;
    }
}

      #card 子服务
      - id: card-service
        uri: lb://card-service
        predicates: 
        - Path=/card/**
        filters:
        - StripPrefix=1
        #配置快速熔断失败调用
        - name: Hystrix
          args: 
            name: fallbackcmd
            fallbackUri: forward:/error/fallback

若服务暂时不可用,发起重试后又能返回正常,可以通过设置重试次数,来确保服务的可用性。

yaml 复制代码
      #card子服务
      - id: card-service
        uri: lb://card-service
        predicates: 
        - Path=/card/**
        filters:
        - StripPrefix=1
        - name: Hystrix
          args: 
            name: fallbackcmd
            fallbackUri: forward:/error/fallback
        - name: Retry
          args: 
              #重试 3 次,加上初次访问,正确执行应当是 4 次访问
            retries: 3
            statuses: 
            - OK
            methods: 
            - GET
            - POST
            #异常配置,与代码中抛出的异常保持一致
            exceptions: 
            - com.mall.parking.common.exception.BusinessException

如何测试呢?可以代码中增加异常抛出,来测试请求是否重试 3 次,前端调用时,通过网关访问此服务调用,可以发现被调用次数是 4 次。

java 复制代码
/* 这里抛出异常是为了测试spring-cloud-gateway的retry机制是否正常运行
	* if (StringUtils.isEmpty("")) {
		throw new BusinessException("test retry function");
    }*/

实现服务限流

为什么要限流,当服务调用压力突然增大时,对系统的冲击是很大的,为保证系统的可用性,做一些限流措施很有必要。

常见的限流算法有:令牌桶、漏桶等,Gateway 组件内部默认实现了 Redis+Lua 进行限流,可以通过自定义的方式来指定是根据 IP、用户或是 URI 来进行限流,下面我们来一控究竟。

Spring Cloud Gateway 默认提供的 RedisRateLimter 的核心逻辑为判断是否取到令牌的实现,通过调用 META-INF/scripts/requestratelimiter.lua 脚本实现基于令牌桶算法限流,我们来看看如何借助这个功能来达到我们的目的。

引入相应 jar 包的支持:

xml 复制代码
<!--基于 reactive stream 的redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>

配置基于 IP 进行限流,比如在商场兑换优惠券时,在固定时间内,仅有固定数量的商场优惠券来应对突然间的大量请求,很容易出现高峰交易的情况,导致服务卡死不可用。

yaml 复制代码
- name: RequestRateLimiter
  args: 
  	redis-rate-limiter.replenishRate: 3 #允许用户每秒处理多少个请求
    redis-rate-limiter.burstCapacity: 5 #令牌桶的容量,允许在一秒钟内完成的最大请求数
    key-resolver: "#{@remoteAddrKeyResolver}" #SPEL 表达式去的对应的 bean

上文的 KeyResolver 配置项是用来定义按什么规则来限流,比如本次采用 IP 进行限流,编写对应的实现类实现此接口:

java 复制代码
public class AddrKeyResolver implements KeyResolver {

    @Override
    public Mono<String> resolve(ServerWebExchange exchange) {
        return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
    }
}

在启动类进行 @Bean 定义:

java 复制代码
@Bean
public AddrKeyResolver addrKeyResolver() {
    return new AddrKeyResolver();
}

到此,配置完毕,下面来验证配置是否生效。

测试限流是否生效

前期我们采用了 Postman 组件进行了不少接口测试工作,其实它可以提供模拟并发测试功能(如果要真实现真正的并发测试,建议采用 Apache JMeter 工具),不少用过的小伙伴尚未发现这一功能,这里就带大家一起使用 Postman 来发起模拟并发测试,操作步骤如下。

1. 建立测试脚本目录

2. 将测试请求放入目录

3. 运行脚本

4. 打开终端,进入 Redis 对应的库,输入 monitor 命令,监控 Redis 命令的执行情况。

点击上图"Run"按钮,查看 Redis 命令的执行情况。查看 PostMan 控制台,可以看到有 3 次已经被忽略执行。

到此,通过原生限流组件可以正常使用,通过 IP 是简单的限流,往往还会有更多个性化的需求,这个时候就需要定制来完成高阶功能。

实现跨域支持

时下流行的系统部署架构基本是前、后端独立部署,由此而直接引发另一个问题:跨域请求。必须要在网关层支持跨域,不然无法将请求路由到正确的处理节点。这里提供两种方式,一种是代码编写,一种是能过配置文件配置,建议采用配置方式完成。

代码方式
java 复制代码
@Configuration
public class CORSConfiguration {

    @Bean
    public CorsWebFilter corsWebFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(Boolean.TRUE);
        //config.addAllowedMethod("*");
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addExposedHeader("setToken");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
        source.registerCorsConfiguration("/**", config);

        return new CorsWebFilter(source);
    }
}
配置文件配置
yaml 复制代码
spring:
  cloud:
    gateway:
      discovery:
      # 跨域
      globalcors:
        corsConfigurations:
          '[/**]':
            allowedHeaders: "*"
            allowedOrigins: "*"
            # 为保证请求的安全,项目中只支持 get 或 post 请求,其它请求全部屏蔽,以免导致多余的问题
            allowedMethods:
            - POST

本文到此,网关中路由配置、熔断失败、请求限流、请求跨域等常见的共性问题都得到初步的解决,相信随着使用的深入,还有更多高阶的功能等待大家去开发使用。留一个思考题:除了 Spring Cloud Gateway 之外,你还知道其它中间件可以实现网关功能吗?不妨去调研一番。

17 集成网关后怎么做安全验证------统一鉴权

商场停车场景中,除了极少数功能不需要用户登陆外(如可用车位数),其余都是需要用户在会话状态下才能正常使用的功能。上个章节中提到,要在网关层实现统一的认证操作,本篇就直接带你在网关层增加一个公共鉴权功能,来实现简单的认证,采用轻量级解决方案 JWT 的方式来完成。

为什么选 JWT

JSON Web Token(缩写 JWT)是比较流行的轻量级跨域认证解决方案,Tomcat 的 Session 方式不太适应分布式环境中,多实例多应用的场景。JWT 按一定规则生成并解析,无须存储,仅这一点要完爆 Session 的存储方式,更何况 Session 在多实例环境还需要考虑同步问题,复杂度无形中又增大不少。

由于 JWT 的这种特性,导致 JWT 生成后,只要不过期就可以正常使用,在业务场景中就会存在漏洞,比如会话退出时,但 token 依旧可以使用(token 一旦生成,无法更改),此时就需要借助第三方的手段,来配置 token 的验证,防止被别有用意的人利用。

服务只有处于无状态条件下,才能更好的扩展,否则就需要维护状态,增加额外的开销,反而不利于维护扩展,而 JWT 恰恰帮助服务端实例做到了无状态化。

JWT 应用的两个特殊场景
  1. 会话主动退出。必须结合第三方来完成,如 Redis 方案:会话主动退出时,将 token 写入缓存中,后期所有请求在网关层验证时,先判定缓存中是否存在,若存在则证明 token 无效,提示去登陆。
  2. 用户一直在使用系统,但 JWT 失效。假如 JWT 有效期是 30 分钟,如果用户一直在使用,表明处于活跃状态,不能直接在 30 分钟后用用户踢出去登陆,用户体验很糟糕。依照 Session 方式下的解雇方案,只要用户在活跃,有效期就要延长。但 JWT 本身又无法更改,这时就需要刷新 JWT 来保证体验的流畅性。方案如下:当检测到即将过期或已经过期时,但用户依旧在活跃(如果判定在活跃?可以将用户的每次请求写入缓存,通过时间间隔判定),则生成新 token 返回给前端,使用新的 token 发起请求,直到主动退出或失效退出。

使用 JWT

在网关层引入 jar 包:

xml 复制代码
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
编写 JWT 工具类

工具类功能集中于生成 token 与验证 token:

java 复制代码
@Slf4j
public class JWTUtils {

    /**
     * 由字符串生成加密 key,此处的 key 并没有代码中写死,可以灵活配置
     * 
     * @return
     */
    public static SecretKey generalKey(String stringKey) {
        byte[] encodedKey = Base64.decodeBase64(stringKey);
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return key;
    }

    /**
     * createJWT: 创建 jwt<br/>
     *
     * @author guooo
     * @param id        唯一 id,uuid 即可
     * @param subject   json 形式字符串或字符串,增加用户非敏感信息存储,如 user tid,与 token 解析后进行对比,防止乱用
     * @param ttlMillis 有效期
     * @param stringKey
     * @return jwt token
     * @throws Exception
     * @since JDK 1.6
     */
    public static String createJWT(String id, String subject, long ttlMillis, String stringKey) throws Exception {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        SecretKey key = generalKey(stringKey);
        JwtBuilder builder = Jwts.builder().setIssuer("").setId(id).setIssuedAt(now).setSubject(subject)
                .signWith(signatureAlgorithm, key);
        if (ttlMillis >= 0) {
            long expMillis = nowMillis + ttlMillis;
            Date exp = new Date(expMillis);
            builder.setExpiration(exp);
        }
        return builder.compact();
    }

    /**
     * parseJWT: 解密 jwt <br/>
     *
     * @author guooo
     * @param jwt
     * @param stringKey
     * @return
     * @throws ExpiredJwtException
     * @throws UnsupportedJwtException
     * @throws MalformedJwtException
     * @throws SignatureException
     * @throws IllegalArgumentException
     * @since JDK 1.6
     */
    public static Claims parseJWT(String jwt, String stringKey) throws ExpiredJwtException, UnsupportedJwtException,
            MalformedJwtException, SignatureException, IllegalArgumentException {
        SecretKey key = generalKey(stringKey);
        Claims claims = Jwts.parser().setSigningKey(key).parseClaimsJws(jwt).getBody();
        return claims;
    }

    public static boolean isTokenExpire(String jwt, String stringKey) {
        Claims aClaims = parseJWT(jwt, stringKey);
        // 当前时间与 token 失效时间比较
        if (LocalDateTime.now().isAfter(LocalDateTime.now()
                .with(aClaims.getExpiration().toInstant().atOffset(ZoneOffset.ofHours(8)).toLocalDateTime()))) {
            log.info("token is valide");
            return true;
        } else {
            return false;
        }
    }

    public static void main(String[] args) {
        try {
            String key = "eyJqdGkiOiI1NGEzNmQ5MjhjYzE0MTY2YTk0MmQ5NTg4NGM2Y2JjMSIsImlhdCI6MTU3OTE2MDkwMiwic3ViIjoiMTIxMiIsImV4cCI6MTU3OTE2MDkyMn0";
            String token = createJWT(UUID.randomUUID().toString().replace("-", ""), "1212", 2000, key);
            System.out.println(token);
            parseJWT(token, key);
//            Thread.sleep(2500);
            Claims aClaims = parseJWT(token, key);
            System.out.println(aClaims.getExpiration());
            if (isTokenExpire(token, key)) {
                System.out.println("过期了");
            } else {
                System.out.println("normal");
            }
            System.out.println(aClaims.getSubject().substring(0, 2));

        } catch (ExpiredJwtException e) {
            System.out.println("又过期了");
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}
校验 token

需要要结合 Spring Cloud Gateway 的网关过滤器来验证 token 的可用性,编写过滤器:

java 复制代码
@Component
@Slf4j
public class JWTFilter implements GlobalFilter, Ordered {

    @Autowired
    JWTData jwtData;

    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        String url = exchange.getRequest().getURI().getPath();

        // 跳过不需要验证的路径
        if (null != jwtData.getSkipUrls() && Arrays.asList(jwtData.getSkipUrls()).contains(url)) {
            return chain.filter(exchange);
        }

        // 获取 token
        String token = exchange.getRequest().getHeaders().getFirst("token");
        ServerHttpResponse resp = exchange.getResponse();
        if (StringUtils.isEmpty(token)) {
            // 没有 token
            return authError(resp, "请先登陆!");
        } else {
            // 有 token
            try {
                JWTUtils.parseJWT(token, jwtData.getTokenKey());
                log.info("验证通过");
                return chain.filter(exchange);
            } catch (ExpiredJwtException e) {
                log.error(e.getMessage(), e);
                return authError(resp, "token过期");
            } catch (Exception e) {
                log.error(e.getMessage(), e);
                return authError(resp, "认证失败");
            }
        }
    }

    /**
     * 认证错误输出
     * 
     * @param resp    响应对象
     * @param message 错误信息
     * @return
     */
    private Mono<Void> authError(ServerHttpResponse resp, String message) {
        resp.setStatusCode(HttpStatus.UNAUTHORIZED);
        resp.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        CommonResult<String> returnData = new CommonResult<>(org.apache.http.HttpStatus.SC_UNAUTHORIZED + "");
        returnData.setRespMsg(message);
        String returnStr = "";
        try {
            returnStr = objectMapper.writeValueAsString(returnData.getRespMsg());
        } catch (JsonProcessingException e) {
            log.error(e.getMessage(), e);
        }
        DataBuffer buffer = resp.bufferFactory().wrap(returnStr.getBytes(StandardCharsets.UTF_8));
        return resp.writeWith(Flux.just(buffer));
    }

    @Override
    public int getOrder() {
        return -200;
    }

}

上文提到 key 是 JWT 在生成或验证 token 时一个关键参数,就像生成密钥种子一样。此值可以配置在 application.properties 配置文件中,也可以写入 Nacos 中。过滤器中使用到的 JWTData 类,主要用于存储不需要鉴权的请求地址与 JWT 种子 key 的值。

properties 复制代码
jwt:
  token-key: eyJqdGkiOiI1NGEzNmQ5MjhjYzE0MTY2YTk0MmQ5NTg4NGM2Y2JjMSIsImlhdCI6MTU3OTE2MDkwMiwic3ViIjoiMTIxMiIsImV4cCI6MTU3OTE2MDkyMn0
  skip-urls: 
  - /member-service/member/bindMobile
  - /member-service/member/logout

@Component
@Data
@ConfigurationProperties(prefix = "jwt")
public class JWTData {

    public String tokenKey;

    private String[] skipUrls;
}

至此,基本的配置与相关功能代码已经完备,下一步进入测试。

测试可用性

本次主要来验证特定下,是否会对 token 进行验证,由于 filter 是基于网关的 GlobalFilter,会拦截所有的路由请求,当是无须验权的请求时,则直接转发路由。

先用 JWTUtils 工具,输出一个正常的 token,采用 Postman 工具进行"商场用户日常签到功能请求"验证,发现请求成功。

稍等数秒钟,待 token 自动失效后,再重新发起请求,结果如下图所示,请求直接在网关层被拦截返回,提示:"token 过期",不再向后端服务转发。

做另外一个测试:伪造一个错误的 token,进行请求,验证结果如下图所示,请求直接在网关层拦截返回,同样不再向后端服务转发。

至此,一个轻量级的网关鉴权方案完成,虽简单但很实用。在应对复杂场景时,还需要配合其它组件或功能来加固服务,保证服务的安全性。比如鉴权通过后,哪些功能有权操作,哪有没有,还需要基于角色权限配置来完成。这在管理系统中很常见,本案例中未体现此块功能,你可以在本案例中尝试增加这块的功能来验证一下,加深对 JWT 的理解。

相关推荐
云创智城-yuncitys17 小时前
SpringCloud 架构在智慧交通路侧停车系统中的实践:从技术落地到城市级服务升级
spring·spring cloud·架构·智慧城市·停车系统·充电系统源码
番茄Salad18 小时前
Spring Boot临时解决循环依赖注入问题
java·spring boot·spring cloud
kkkkk0211061 天前
微服务学习笔记(黑马商城)
java·spring boot·spring·spring cloud·sentinel·mybatis·java-rabbitmq
洛克大航海1 天前
5-SpringCloud-服务链路追踪 Micrometer Tracing
后端·spring·spring cloud·zipkin·micrometer
我命由我123451 天前
Spring Cloud - Spring Cloud 微服务概述 (微服务的产生与特点、微服务的优缺点、微服务设计原则、微服务架构的核心组件)
java·运维·spring·spring cloud·微服务·架构·java-ee
我命由我123451 天前
Spring Cloud - Spring Cloud 注册中心与服务提供者(Spring Cloud Eureka 概述、微服务快速入门、微服务应用实例)
java·spring boot·spring·spring cloud·微服务·eureka·java-ee
Java 码农1 天前
Spring Cloud Eureka 的实现原理
spring·spring cloud·eureka
小猪咪piggy2 天前
【微服务】(1) Spring Cloud 概述
java·spring cloud·微服务
choice of2 天前
Sentinel:阿里云高并发流量控制
笔记·spring cloud·sentinel