哈士奇发布微服务相关技术栈笔记-上

什么是微服务

  • 微服务技术不等于SpringCloud。

  • 人们通过服务网关服务集群请求路由、进行负载均衡,服务集群通过注册中心配置中心分别拉取或注册服务信息与配置信息,向数据库进行分布式搜索,服务集群同时使用消息队列。分布式日志服务

  • 通过Jenkins对微服务进行编译,再通过docker进行打包,最后通过kubernetes或rancher进行自动化部署。

1、认识微服务

1.1、单体架构

简单方便,高度耦合,扩展性差,适合小型项目。

1.2、分布式架构特点

松耦合,扩展性好,但架构复杂,难度大。适合大型互联网项目。

1.3、微服务

一种良好的分布式架构方案

  • 优点:拆分粒度更小、服务更独立、耦合度更低

  • 缺点:架构非常复杂,运维、监控、部署难度提高

1.4、微服务技术对比

DubboSpringCloudSpringCloudAlibaba
注册中心zookeeper、RedisEureka、ConsulNacos、Eureka
服务远程调用Dubbo协议Feign(http协议)Dubbo、Feign
配置中心SpringCloudConfigSpringCloudConfig、Nacos
服务网关SpringCloudGateway、ZuulSpringCloudGateway、Zuul
服务监控和保护dubbo-admin,功能弱HystrixSentinel

  • 第一类

    • SpringCloud + Feign

      • 使用SpringCloud技术栈

      • 服务接口采用Restful风格

      • 服务调用采用Feign方式

    • SpringCloudAlibaba + Feign

      • 使用SpringCloudAlibaba技术栈

      • 服务接口采用Restful风格

      • 服务调用采用Feign方式

  • 第二类

    • SpringCloudAlibaba + Dubbo

      • 使用SpringCloudAlibaba技术栈

      • 服务接口采用Dubbo协议标准

      • 服务调用采用Dubbo方式

    • Dubbo原始模式

      • 基于Dubbo老旧技术体系

      • 服务接口采用Dubbo协议标准

      • 服务调用采用Dubbo方式

1.5、SpringCloud

  • SpringCloud是目前国内使用最广泛的微服务框架。

  • SpringCloud集成了各种微服务功能组件,并基于SpringBoot实现了这些组件的自动装配,从而提供了良好的开箱即用体验

    • 服务注册发现

      • Eureka、Nacos、Consul

    • 服务远程调用

      • OpenFeign、Dubbo

    • 服务链路监控

      • Zipkin、Sleuth

    • 统一配置管理

      • SpringCloudConfig、Nacos

    • 统一网关路由

      • SpringCloudGateway、Zuul

    • 流控、降级、保护

      • Hystix、Sentinel

2、服务拆分及远程调用

2.1、服务拆分

注意事项

  • 不同微服务,不要重复开发相同业务

  • 微服务数据独立,不要访问其它微服务的数据库

  • 微服务可以将自己的业务暴露为接口,供其它微服务调用

2.2、案例

2.2.1、用户微服务模块

用户实体类

import lombok.Data;

@Data
public class User {
    private Long id;
    private String username;
    private String address;
}

用户Mapper类,即Dao层  

import cn.itcast.user.pojo.User;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;

public interface UserMapper {
    
    @Select("select * from `cloud-user`.tb_user where id = #{id}")
    User findById(@Param("id") Long id);
}

用户业务接口类

import cn.itcast.user.mapper.UserMapper;
import cn.itcast.user.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public User queryById(Long id) {
        return userMapper.findById(id);
    }
}

 用户控制层

import cn.itcast.user.pojo.User;
import cn.itcast.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    /**
     * 路径: /user/110
     *
     * @param id 用户id
     * @return 用户
     */
    @GetMapping("/{id}")
    public User queryById(@PathVariable("id") Long id) {
        return userService.queryById(id);
    }
}

 用户模块启动类

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.mybatis.spring.annotation.MapperScan;

@MapperScan("cn.itcast.user.mapper")
@SpringBootApplication
public class UserApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserApplication.class, args);
    }
}
2.2.2、订单微服务模块

订单实体类,这个模块中仍需要用户实体类

import lombok.Data;

@Data
public class Order {
    private Long id;
    private Long price;
    private String name;
    private Integer num;
    private Long userId;
    private User user;
}

 订单Mapper类

import cn.itcast.order.pojo.Order;
import org.apache.ibatis.annotations.Select;

public interface OrderMapper {

    @Select("select * from `cloud-order`.tb_order where id = #{id}")
    Order findById(Long id);
}

 订单业务接口类

import cn.itcast.order.mapper.OrderMapper;
import cn.itcast.order.pojo.Order;
import cn.itcast.order.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    public Order queryOrderById(Long orderId) {
        // 1. 查询订单
        Order order = orderMapper.findById(orderId);
        // 4. 返回order
        return order;
    }
}

订单控制层

import cn.itcast.order.pojo.Order;
import cn.itcast.order.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("order")
public class OrderController {

   @Autowired
   private OrderService orderService;

    @GetMapping("{orderId}")
    public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
        // 根据id查询订单并返回
        return orderService.queryOrderById(orderId);
    }
}

 订单模块启动类

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;

@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}
2.2.3、根据订单id查询订单功能

需求:根据订单id查询订单的同时,把订单所属的用户信息一起返回

注册RestTemplate

在order-service的OrderApplication中注册RestTemplate,只是暂时的,后期会另建包

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;

@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {

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

    /**
     * 创建RestTemplate并注入到Spring容器
     * @return 返回RestTemplate
     */
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

服务远程调用RestTemplate

在order-service的OrderService中通过RestTemplate进行方法调用

import cn.itcast.order.mapper.OrderMapper;
import cn.itcast.order.pojo.Order;
import cn.itcast.order.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private RestTemplate restTemplate;

    public Order queryOrderById(Long orderId) {
        // 1. 查询订单
        Order order = orderMapper.findById(orderId);
        // 2. 利用RestTemplate发起http请求,通过order中的用户id查询用户信息
        // 2.1. restTemplate需要url路径,但这是一个弊端
        String url = "http://localhost:8081/user/" + order.getUserId();
        // 2.2. 发送http请求,实现远程调用
        User user = restTemplate.getForObject(url, User.class);
        // 3. 封装user到order
        order.setUser(user);
        // 4. 返回order
        return order;
    }
}

2.3、远程调用

  • 服务提供者:一次业务中,被其它微服务调用的服务,即提供接口给其它微服务。

  • 服务消费者:一次业务中,调用其它微服务的服务,即调用其它微服务提供的接口。

  • 提供者与消费者是相对的

  • 一个服务既可以是服务提供者和服务消费者

3、Eureka注册中心

3.1、远程调用问题

  • 服务消费者该如何获取服务提供者的地址信息?

    • 服务提供者启动时向eureka注册自己的信息

    • eureka保存这些信息

    • 消费者根据服务名称向eureka拉取提供者信息

  • 如果有多个服务提供者,消费者该如何选择?

    • 服务消费者利用负载均衡算法,从服务列表中挑选一个

  • 消费者如何得知服务提供者的健康状态?

    • 服务提供者会每隔30秒向EurekaServer发送心跳请求,报告健康状态

    • eureka会更新记录服务列表信息,心跳不正常会被移除

    • 消费者就可以拉取最新的信息

在Eureka架构中,微服务角色有两类

  • EurekaServer:服务端、注册中心

    • 记录服务信息

    • 心跳监控

  • EurekaClient:客户端

    • Provider:服务提供者,例如案例中的user-service

      • 注册自己的信息到EurekaServer

      • 每隔30秒向EurekaServer发送心跳

    • Consumer:服务消费者,例如案例中的order-service

      • 根据服务名称从EurekaServer拉取服务列表

      • 基于服务列表做负载均衡,选中一个微服务后发起远程调用

3.2、eureka原理

举例

eureka-server:注册中心

eureka-client:服务消费者【order-service:8080】、服务提供者【user-service:8081、8082、8083】

注册中心为服务消费者和服务提供者注册服务信息,服务消费者通过负载均衡从注册中心拉取服务,再远程调用服务提供者的接口,服务提供者每隔一段时间都会向注册中心发送请求,以确认健康状态【如果没有发送请求,则移除该服务提供者】

3.3、搭建EurekaServer

搭建EurekaServer服务步骤

  • 创建项目,引入spring-cloud-starter-netflix-eureka-server的依赖,并创建eureka启动类

<!-- eureka服务端 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
  • 编写启动类,添加@EnableEurekaServer注解

@EnableEurekaServer
@SpringBootApplication
public class EurekaApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaApplication.class, args);
    }
}
  • 添加application.yml文件,编写以下配置

server:
  port: 10086 # 服务端口
spring:
  application:
    name: eurekaserver # 服务名称
eureka:
  client:
    service-url: # eureka的地址信息
      defaultZone: http://127.0.0.1:10086/eureka/

3.4、服务注册

将user-service项目引入spring-cloud-starter-netflix-eureka-client的依赖

  • 在user-service项目引入spring-cloud-starter-netflix-eureka-client的依赖

<!--eureka客户端依赖-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
  • 在application.yml文件,编写下面配置

spring:
  application:
    name: userservice # user服务的服务名称
eureka:
  client:
    service-url: # eureka的地址信息
      defaultZone: http://127.0.0.1:10086/eureka/

如果想将order-service也进行服务注册,请重复以上步骤,顺便更改服务名称为orderservice

如果我们想要将同一个服务注册多个,以下示例

比如将user-service设置多个,首先右键UserApplication,点击Copy Configuration,然后在VM options中设置-Dserver.port=8082【-D代表参数】,做此步骤是为了保证端口不发生冲突

3.5、服务发现

在order-service完成服务拉取

服务拉取是基于服务名称获取服务列表,然后在对服务列表做负载均衡

  • 修改OrderService的代码,修改访问的url路径,用服务名代替ip、端口

String url = "http://userservice/user/" + order.getUserId();

改完如下:

import cn.itcast.order.mapper.OrderMapper;
import cn.itcast.order.pojo.Order;
import cn.itcast.order.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private RestTemplate restTemplate;

    public Order queryOrderById(Long orderId) {
        // 1. 查询订单
        Order order = orderMapper.findById(orderId);
        // 2. 利用RestTemplate发起http请求,通过order中的用户id查询用户信息
        // 2.1. restTemplate需要url路径,但这是一个弊端
//        String url = "http://localhost:8081/user/" + order.getUserId();
        // 2.1. 更改代码如下
        String url = "http://userservice/user" + order.getUserId();
        // 2.2. 发送http请求,实现远程调用
        User user = restTemplate.getForObject(url, User.class);
        // 3. 封装user到order
        order.setUser(user);
        // 4. 返回order
        return order;
    }
}
  • 在order-service项目的启动类OrderApplication中的RestTemplate添加负载均衡注释

@Bean
@LoadBalanced
public RestTemplate restTempalte() {
    return new RestTemplate();
}

3.6、总结

  • 搭建EurekaServer

    • 引入eureka-server依赖

    • 添加@EnableEurekaServer注解

    • 在application.yml中配置eureka地址

  • 服务注册

    • 引入eureka-client依赖

    • 在application.yml中配置eureka地址

  • 服务发现

    • 引入eureka-client依赖

    • 在application.yml中配置eureka地址

    • 给RestTemplate添加@LoadBalanced注解

    • 用服务提供者的服务名称远程调用

4、Ribbon负载均衡

4.1、负载均衡原理

order-service对Ribbon负载均衡发起请求【http://userservice/user/1】,Ribbon从eureka-server中拉取userservice,返回服务列表【localhost:8081、localhost:8082】给Ribbon,通过几种方式选取服务,如轮询方式。

Ribbon负载均衡里的操作

将请求交给LoadBalancerInterceptor负载均衡拦截器里的RibbonLoadBanlancerClient,DynamicServerListLoadBalancer从RibbonLoadBanlancerClient获取userservice里url中的服务id,将其进行负载均衡给IRule,IRule根据规则选择某个服务【比如从localhost:8081或8082中获取8081】后,传回给RibbonLoadBanlancerClient,RibbonLoadBanlancerClient修改url,发起请求到8081,最后选取服务。

4.2、负载均衡策略

Ribbon的负载均衡规则是一个叫做IRule的接口来定义的,每一个子接口都是一种规则

  • IRule

    • AbstractLoadBalancerRule

      • RetryRule

      • ClientConfigEnabledRoundRobinRule

        • BestAvailableRule

        • PredicateBasedRule

          • AvailabilityFilteringRule

          • ZoneAvoidanceRule

      • RoundRobinRule

        • WeightedResponseTimeRule

      • RandomRule

内置负载均衡规则类规则描述
RoundRobinRule简单轮询服务列表来选择服务器。它是Ribbon默认的负载均衡规则。
AvailabilityFilteringRule对以下两种服务器进行忽略:(1)在默认情况下,这台服务器如果3次连接失败,这台服务器就会被设置为“短路”状态。短路状态将持续30秒,如果再次连接失败,短路的持续时间就会几何级地增加。(2)并发数过高的服务器。如果一个服务器的并发连接数过高,配置了AvailabilityFilteringRule规则的客户端也会将其忽略。并发连接数的上限,可以由客户端的<clientName>.<clientConfigNameSpace>.ActiveConnectionsLimit属性进行配置。
WeightedResponseTimeRule为每一个服务器赋予一个权重值。服务器响应时间越长,这个服务器的权重就越小。这个规则会随机选择服务器,这个权重值会影响服务器的选择。
ZoneAvoidanceRule以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询
BestAvailableRule忽略那些短路的服务器,并选择并发数较低的服务器
RandomRule随机选择一个可用的服务器
RetryRule重试机制的选择逻辑

通过定义IRule实现可以修改负载均衡规则,有两种方式

  • 代码方式:在order-service中的OrderApplication类中,定义一个新的IRule【全局】

/**
* 随机
* @return
*/
@Bean
public IRule randomRule() {
    return new RandomRule();
}
  • 配置文件方式:在order-service的application.yml文件中,添加新的配置也可以修改规则【针对某个微服务而言】

userservice:
  ribbon:
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule # 负载均衡规则

4.3、懒加载和饥饿加载

Ribbon默认是采用懒加载,即第一次访问时会创建LoadBalanceClient,请求时间会很长。而饥饿加载则会在项目启动时创建,降低第一次访问的耗时,通过下面配置开启饥饿加载,在order-service中

指定某个服务

ribbon:
  eager-load:
    enabled: true # 开启饥饿加载
    clients: userservice # 是个集合,如果是单个,则指定对userservice这个服务饥饿加载

指定多个服务

ribbon:
  eager-load:
    enabled: true # 开启饥饿加载
    clients: # 是个集合,如果多个
      - userservice
      - xxxservice

4.4、总结

  • Ribbon负载均衡规则

    • 规则接口是IRule

    • 默认实现ZoneAvoidanceRule,根据zone选择服务列表,然后轮询

  • 负载均衡自定义方式

    • 代码方式:配置灵活,但修改时需要重新打包发布

    • 配置方式:直观,方便,无需重新打包发布,但是无法做全局配置

  • 饥饿加载

    • 开启饥饿加载

    • 指定饥饿加载的微服务名称

5、Nacos注册中心

Nacos是阿里巴巴的产品,现在是SpringCloud中的一个组件。相比Eureka功能更加丰富

5.1、Nacos安装指南

5.1.1、Window安装

下载安装包

在Nacos的GitHub页面,提供下载链接,可以下载编译好的Nacos服务端或者源代码:

GitHub主页:GitHub - alibaba/nacos: an easy-to-use dynamic service discovery, configuration and service management platform for building cloud native applications.

GitHub的Release下载页:Releases · alibaba/nacos · GitHub

解压

将安装包进行解压到任意的非中文目录下

  • 目录说明

    • bin:启动脚本

    • conf:配置文件

端口配置

Nacos的默认端口是8848,如果电脑上其它进程占用了该端口,请尝试关闭该进程。

如果无法关闭占用8848端口的进程,也可以进入到nacos中的conf目录,修改配置文件中的端口,在文件内容中的部分是,如下

### Default Web context path:
server.servlet.contextPath=/nacos
### Default Web server port:
server.port=8848

启动

启动 so easy,进入bin目录,然后执行命令,执行成功会出现大大的NACOS~~

windows命令:
startup.cmd -m standalone #【-m代表模式;后面那个是单机,后面会有集群】
5.1.2、Linux安装

通过虚拟机安装docker,然后通过docker去拉取nacos镜像进行安装即可

5.2、服务注册到Nacos

  • 首先,在cloud-demo父工程中添加spring-cloud-alibaba的管理依赖

<dependency>
    <groupId>com.alibaba,cloud</groupID>
    <artifactId>spring-cloud-alibaba-dependencies</artifactId>
    <version>2.2.5.RELEASE</version>
    <type>pom</type>
    <scope>import</scope>
</dependency>
  • 然后,注释掉order-service和user-service中原有的eureka依赖

  • 再者,添加nacos的客户端依赖

<!-- nacos客户端依赖 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
  • 其次,修改user-service&order-service中的application.yml文件,注释eureka地址,添加nacos地址

spring:
  cloud:
    nacos:
      server-addr: localhost:8848 # nacos 服务端地址
  • 最后,启动并测试

5.3、Nacos服务分级存储模型

  • 服务

    • 集群

      • 实例

      • 实例

    • 集群

      • 实例

      • 实例

5.3.1、服务集群属性
  • 修改application.yml,添加如下内容

spring:
  cloud:
    nocos:
      server-addr: localhost:8848 # nacos 服务端地址
      discovery:
        cluster-name: FJ # 配置集群名称,也就是机房位置,例如:FJ,福建
  • 在Nacos控制台可以看到集群变化

####

5.3.2、总结
  • Nacos服务分级存储模型

    • 一级是服务:例如userservice

    • 二级是集群:例如福建

    • 三级是实例:例如福建机房的某台部署了userservice的服务器

  • 如何设置实例的集群属性

    • 修改application.yml文件,添加spring.cloud.nacos.discovery.cluster-name属性即可

5.4、Nacos负载均衡策略

  • 首先,修改order-service中的application.yml,设置集群为FJ:

spring:
  cloud:
    nacos:
      server-addr: localhost:8848 # nacos服务地址
      discovery:
        cluster-name: FJ # 配置集群名称,也就是机房位置,例如:FJ,福建
  • 然后,在order-service中设置负载均衡的IRule为NacosRule,这个规则优先会寻找与自己同集群的服务

userservice:
  ribbon:
    NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule # 负载均衡规则
  • 注意将user-service的权重都设置为1

总结

  • NacosRule负载均衡策略

    • 优先选择同集群服务实例列表

    • 本地集群找不到提供者,才去其它集群寻找,并且会报警告

    • 确定了可用实例列表后,在采用随机负载均衡挑选实例

5.5、服务实例的权重设置

实际部署中会出现这样的场景

  • 服务设备性能有差异,部分实例所在机器性能较好,另一些较差,希望性能好的机器能承担更多的用户请求。

    • Nacos提供了权重配置来控制访问频率,权重越大则访问频率越高

如何更改权重

  • 首先,在Nacos控制台可以设置实例的权重值,选中实例后面的编辑按钮

  • 最后,将权重设置为0.1,测试可以发现8081被访问到的频率大大降低【权重值一般在0-1】【一般用于不停机更新】

总结

  • Nacos控制台可以设置实例的权重值,0-1之间

  • 同集群内的多个实例,权重越高被访问的频率越高

  • 权重设置为0则完全不会被访问

5.6、Nacos环境隔离【namespace】

Nacos中服务存储和数据存储的最外层都是一个名为namespace的东西,用来做最外层隔离。

Nacos》Group》Service/Data

步骤

  • 首先,在Nacos控制台可以创建namespace,用来隔离不同环境,点击命名空间,再点击新建命名空间

  • 然后,填写一个新的命名空间信息,写入命名空间名及描述

  • 其次,保存后会在控制台看到这个命名空间的id

  • 再者,在项目中修改order-service的application.yml,添加namespace

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/cloud_order?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=utf8&characterSetResults=utf8&useSSL=false&serverTimezone=GMT%2B8
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
  cloud:
    nacos:
      server-addr: localhost:8848
      discovery:
        cluster-name: FJ # 福建
        namespace: xxxxxxx-xxxx-xxxx-xxx-xxxxxxxx #命名空间 dev环境,填ID
  • 再然后,重启order-service后,再来查看控制台

  • 最后,此时访问order-service,因为namespace不同,会导致找不到userservice,控制台会报错

总结

  • Nacos环境隔离

    • namespace用来做环境隔离

    • 每个namespace都有唯一id

    • 不同namespace下的服务不可见

5.7、Nacos注册中心细节分析

  • nacos注册中心为服务提供者注册服务信息,服务消费者定时拉取注册中心的服务,服务消费者中存在定时更新服务列表缓存并进行远程调用服务提供者中的服务。

  • nacos注册中心对于临时实例采取的是心跳监测【如果停止直接移除】,对于非临时实例采取的是主动询问【如果停止就等待,不进行移除】。

  • nacos注册中心主动推送变更消息给服务消费者,让其更新服务列表缓存。

服务注册到Nacos时,可以选择注册为临时或非临时实例,通过下面的配置来设置

spring:
  cloud:
    nacos:
      discovery:
        ephemeral: false # 设置为非临时实例

临时实例宕机时,会从nacos的服务列表中移除,而非临时实例不会

5.8、Nacos与Eureka对比

  • Nacos与eureka共同点

    • 都支持服务注册和服务拉取

    • 都支持服务提供者心跳方式做健康检测

  • Nacos与Eureka区别

    • Nacos支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式【主动压力大】

    • 临时实例心跳不正常会被移除,非临时实例则不会被移除

    • Nacos支持服务列表变更的消息推送模式,服务列表更新更及时

    • Nacos集群默认采用AP方式,当集群中存在非临时实例时,采用CP模式;Eureka采用AP方式【AP可用性保证,CP可靠、一致性保证】

5.9、Nacos配置管理

5.9.1、统一配置管理

配置更改热更新

Nacos网页

  • 配置管理中的配置列表,点击+号,新建配置

    • Data ID:是指该配置的名称,名称一般与微服务模块名称一致【服务名称-profile.后缀名】。比如userservice-dev.yaml

    • Group:一般不动,默认分组

    • 描述:对该配置的描述

    • 配置格式:一般情况是yaml和properties

    • 配置内容:一般是有热更新的内容才放到此处

5.9.2、微服务配置拉取

原本步骤

项目启动=》读取本地配置文件application.yml=》创建Spring容器=》加载bean

现在步骤

项目启动=》读取nacos中配置文件=》读取本地配置文件application.yml=》创建Spring容器=》加载bean

注意:如果要从上面这种方式,我们要将application.yml改成bootstrap.yml,因为后者的优先级高,可以先去读取nacos的配置文件

  • 首先,在userservice的pom引入Nacos的配置管理客户端依赖

<!-- nacos配置管理依赖 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
  • 然后,在userservice中的resource目录添加一个bootstrap.yml文件,这个文件是引导文件,优先级高于application.yml

spring:
  application:
    name: userservice # 服务名称
  profiles:
    active: dev # 开发环境,这里是dev
  cloud:
    nacos:
      server-addr: localhost:8848 # Nacos地址
      config:
        file-extension: yaml # 文件后缀名
  • 再者,在nacos页面配置中写入userservice-dev.yaml中写入

pattern:
  dateformat: yyyy-MM-dd HH:mm:ss
  • 最后,通过userservice中将pattern.dateformat这个属性注入到UserController中做测试

@Value("${pattern.dateformat}")
private String dateFormat;
​
@GetMapping("/now")
public String now() {
    return LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateFormat));
}

总结【将配置交给Nacos管理步骤】

  • 在Nacos中添加配置文件

  • 在微服务中引入nacos的config依赖

  • 在微服务中添加bootstrap.yml,配置nacos地址、当前环境、服务名称、文件后缀名。这些决定了程序启动时去nacos读取哪个文件。

5.9.3、配置自动更新

Nacos中的配置文件变更后,微服务无需重启就可以感知。不过需要通过下面两种配置实现

  • 方式一:在@Value注入的变量所在类上添加注解@RefreshScope

@Slf4j
@RestController
@RefreshScope
@RequestMapping("/user")
public class UserController {
​
    @Autowired
    private UserService userService;
​
    @Value("${pattern.dateformat}")
    private String dateFormat;
​
    @GetMapping("/now")
    public String now() {
        return LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateFormat));
    }
}
  • 方式二:使用@ConfigurationProperties注解

首先,userservice创建config包,在包下创建PatternProperties类

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
​
@Data
@Component
@ConfigurationProperties(prefix = "pattern")  // 前缀
public class PatternProperties {
​
    private String dateformat;
}

然后,在UserController以下方法,并注释掉第一种方法

import cn.itcast.user.config.PatternProperties;
import cn.itcast.user.pojo.User;
import cn.itcast.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
​
@Slf4j
@RestController
//@RefreshScope
@RequestMapping("/user")
public class UserController {
​
    @Autowired
    private UserService userService;
​
//    @Value("${pattern.dateformat}")
//    private String dateFormat;
    @Autowired
    private PatternProperties patternProperties;
​
    @GetMapping("/now")
    public String now() {
        return LocalDateTime.now().format(DateTimeFormatter.ofPattern(patternProperties.getDateformat()));
    }
​
    /**
     * 路径: /user/110
     * @param id 用户id
     * @return 用户
     */
    @GetMapping("/{id}")
    public User queryById(@PathVariable("id") Long id) {
        return userService.queryById(id);
    }
}

最后,进行测试

总结

  • 通过@Value注解注入,结合@RefreshScope来刷新

  • 通过@ConfigurationProperties注入,自动刷新

注意事项

  • 不是所有的配置都适合放到配置中心,维护起来比较麻烦

  • 建议将一些关键参数,需要运行时调整的参数放到nacos配置中心,一般都是自定义配置

5.9.4、多环境配置共享

微服务启动时会从nacos读取多个配置文件

  • [spring.application.name]-[spring.profiles.active].yaml,例如:userservice-dev.yaml

  • [spring.application.name].yaml,例如:userservice.yaml

无论profile如何变化,[spring.application.name].yaml这个文件一定会加载,因此多环境共享配置可以写入这个文件

多种配置的优先级

nocos中的配置【当前环境配置{服务名-profile.yaml} > 服务名称.yaml】> 本地配置

5.10、Nacos集群搭建

Nacos生产环境下一定要部署为集群状态。

5.10.1、集群结构图
  • DNS(nacos.com)

    • SLB(Intranet)

      • Nacos(ip1)

      • Nacos(ip2)

      • Nacos(ip3)

其中包含3个nacos节点,然后一个负载均衡器代理3个Nacos,这里负载均衡器可以使用nginx。

假设三个nacos节点的地址

节点ipport
nacos 1192.168.150.18845
nacos 2192.168.150.18846
nacos 3192.168.150.18847

####

5.10.2、搭建集群

搭建步骤

  • 搭建数据库,初始化数据库表结构

  • 下载nacos安装包

  • 配置nacos

  • 启动nacos集群

  • nginx反向代理

初始化数据库

Nacos默认数据存储在内嵌数据库Derby中,不属于生产可用的数据库。

这里演示单点数据库

首先创建一个数据库,命名为nacos,而后导入下面的SQL

xxx

下载nacos

前面有说过

配置nacos,是在nacos文件夹里的

与前面的配置是不同的,进入nacos的conf目录,修改配置文件cluster.conf.example,重命名为cluster.conf,然后添加以下内容

127.0.0.1:8845
127.0.0.1:8846
127.0.0.1:8847

修改application.properties文件,添加数据库配置

spring.datasource.platform=mysql
db.num=1
db.url.0=jdbc:mysql://127.0.0.1:3306/nacos?.....
db.user.0=root
db.password.0=123456

复制三分nacos文件,分别修改文件夹里面的conf里的application.properties里的server.port,分别为8845、8846、8847。

启动nacos

然后分别启动三个nacos节点

startup.cmd

nginx反向代理

首先,下载nginx,解压到任意非中文目录

然后,修改conf/nginx.conf文件,配置如下【注意格式】

upstream nacos-cluster {
    server 127.0.0.1:8845;
    server 127.0.0.1:8846;
    server 127.0.0.1:8847;
}
​
server {
    listen      80;
    server_name localhost;
    
    location /nacos {
        proxy_pass http://nacos-cluster;
    }
}

将项目中的配置文件中的nacos服务地址改成80即可

5.10.3、总结
  • 搭建MYSQL集群并初始化数据库表

  • 下载解压nacos

  • 修改集群配置(节点信息)、数据库配置

  • 分别启动多个nacos节点

  • nginx反向代理

6、Feign远程调用

基于Feign的远程调用,Feign是一个声明式的http客户端,其作用是帮助优雅的实现http请求的发送,解决上面的问题。

6.1、RestTemplate方式调用存在问题

以前利用RestTemplate发起远程调用的代码

String url = "http://userservice/user/" + order.getUserId();
User user = restTemplate.getForObject(url, User.class);

以上代码存在的问题

  • 代码可读性差,编程体验不统一

  • 参数复杂URL难以维护

6.2、定义和使用Feign客户端,自带负载均衡ribbon

使用Feign的步骤如下

  • 引入依赖

<!-- feign客户端依赖-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
  • 在order-service的启动类添加注解开启Feign的功能

@EnableFeignClients
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}
  • 编写Feign客户端

在order-service服务下创建包名为clients,

@FeignClient("userservice") // 要调用哪个服务,这里指明服务名称
public interface UserClient {
    
    @GetMapping("/user/{id}")
    User findById(@PathVariable("id") Long id);
}

主要是基于SpringMVC的注解来声明远程调用的信息,比如:

服务名称:userservice
请求方式:GET
请求路径:/user/{id}
请求参数:Long id
返回值类型:User
  • 使用FeignClient中定义的方法代替RestTemplate

import cn.itcast.order.clients.UserClient;
import cn.itcast.order.mapper.OrderMapper;
import cn.itcast.order.pojo.Order;
import cn.itcast.order.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
​
@Service
public class OrderService {
​
    @Autowired
    private OrderMapper orderMapper;
​
    @Autowired
    private UserClient userClient;
    
    // 基于Feign的远程调用
    public Order queryOrderById(Long orderId) {
        // 1.查询订单
        Order order = orderMapper.findById(orderId);
        // 2.用Feign进行远程调用
        User user = userClient.findById(order.getUserId());
        // 3.封装user到Order
        order.setUser(user);
        // 4.返回
        return order;
    }
​
    // 之前基于RestTemplate的远程调用
//    @Autowired
//    private RestTemplate restTemplate;
//
//    public Order queryOrderById(Long orderId) {
//        // 1. 查询订单
//        Order order = orderMapper.findById(orderId);
//        // 2. 利用RestTemplate发起http请求,通过order中的用户id查询用户信息
//        // 2.1. restTemplate需要url路径,但这是一个弊端
        String url = "http://localhost:8081/user/" + order.getUserId();
//        // 2.1. 更改代码如下
//        String url = "http://userservice/user" + order.getUserId();
//        // 2.2. 发送http请求,实现远程调用
//        User user = restTemplate.getForObject(url, User.class);
//        // 3. 封装user到order
//        order.setUser(user);
//        // 4. 返回order
//        return order;
//    }
}

总结【Feign的使用步骤】

  • 引入依赖

  • 添加@EnableFeignClients注解

  • 编写FeignClient接口

  • 使用FeignClient中定义的方法代替RestTemplate

6.3、自定义Feign配置

Feign运行自定义配置来覆盖默认配置,可以修改的配置如下

类型作用说明
feign.Logger.Level修改日志级别包含四种不同的级别:NONE、BASIC、HEADERS、FULL
feign.codec.Decoder响应结果的解析器http远程调用的结果做解析,例如解析json字符串为java对象
feign.codec.Encoder请求参数编码将请求参数编码,便于通过http请求发送
feign.Contract支持的注解格式默认是SpringMVC的注解
feign.Retryer失败重试机制请求失败的重试机制,默认是没有,不过会使用Ribbon的重试

一般需要配置的就是日志级别

配置Feign日志有两种方式

  • 方式一:配置文件方式

    • 全局生效

    feign:
      client:
        config:
          default: # 这里用default就是全局配置,如果是写服务名称,则是针对某个微服务的配置
            loggerLevel: FULL # 日志级别
    • 局部生效

    feign:
      client:
        config:
          userservice: # 这里用default就是全局配置,如果是写服务名称,则是针对某个微服务的配置
            loggerLevel: FULL # 日志级别
  • 方式二:java代码方式,需要声明一个Bean,在config包中

public class FeignClientConfiguration {
    
    @Bean
    public Logger.Level feignLogLevel() {
        return Logger.Level.BASIC;
    }
}

这种方式如果是全局配置,则把它放到@EnableFeignClients这个注解中:【加在启动类上】

@EnableFeignClients(defaultConfiguration = FeignClientConfiguration.class)

这种方式如果是局部配置,则把它放到@FeignClient这个注解中:【加在clients上,比如UserClient类】

@FeignClient(value = "userservice", configuration = FeignClientConfiguration.class)

总结【Feign的日志配置】

  • 方式一:配置文件,feign.client.config.xxx.loggerLevel

    • 如果xxx是default则代表全局

    • 如果xxx是服务名称,例如userservice则代表某服务

  • 方式二:java代码配置Logger.Level这个Bean

    • 如果在@EnableFeignClients注解声明则代表全局

    • 如果在@FeignClient注解中声明则代表某服务

6.4、Feign的性能优化

Feign底层的客户端实现

  • URLConnection:默认实现,不支持连接池

  • Apache HttpClient:支持连接池

  • OKHttp:支持连接池

因此优化Feign的性能主要包括:

  • 使用连接池代替默认的URLConnection

  • 日志级别,最好用basic或none

使用连接池代替默认的URLConnection

Feign添加HttpClient的支持

  • 引入依赖

<!-- httpClient的依赖 -->
<dependency>
    <groupId>io.github.openfeign</groupId>
    <artifactId>feign-httpclient</artifactId>
</dependency>
  • 配置连接池

feign:
  client:
    config:
      default: # default全局的配置
        loggerLevel: BASIC # 日志级别,BASIC就是基本的请求和响应信息
  httpclient:
    enabled: true # 开启feign对HttpClient的支持
    max-connection: 200 # 最大连接数
    max-connection-per-route: 50 # 每个路径的最大连接数

总结【Feign的优化】

  • 日志级别尽量用basic,少用比basic更高的级别

  • 使用HttpClient或OKHttp代替URLConnection

    • 引入feign-httpClient依赖

    • 配置文件开启httpClient功能,设置连接池参数

6.5、Feign的最佳分析

  • 方式一【继承】:给消费者的FeignClient和提供者的Controller定义统一的父接口作为标准

public interface UserAPI {
    @GetMapping("/user/{id}")
    User findById(@PathVariable("id") Long id);
}
​
// 以下两个方法继承于UserAPI
@FeignClient(value = "userservice")
public interface UserClient extends UserAPI {
    
}
​
@RestController
public class UserController implements UserAPI {
    public User findById(@PathVariable("id") Long id) {
        
    }
}

不建议使用该方法:官网说的,缺点如下

1、服务紧耦合
2、父接口参数列表中的映射不会被继承
  • 方式二【抽取】:将FeignClient抽取为独立模块,并且把接口有关的POJO、默认的Feign配置都放到这个模块中,提供给所有消费者使用

三个模块定义为feign-api、【order-service、pay-service】、user-service{UserController}

【order-service、pay-service】引用feign-api的依赖,并远程调用user-service{UserController}

总结【Feign的最佳分析】

  • 让controller和FeignClient继承同一接口

  • 将FeignClient、POJO、Feign的默认配置都定义到一个项目中,供所有消费者使用

6.6、Feign的最佳实践

实现最佳分析方式二的步骤如下:

  • 首先,创建一个module,命名为feign-api,然后引入feign的starter依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>
</dependencies>
  • 然后,将order-service中编写的UserClient、User、DefaultFeignConfiguration都复制到feign-api项目中

clients包

import cn.itcast.feign.pojo.User;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
​
@FeignClient("userservice") // 要调用哪个服务,这里指明服务名称
public interface UserClient {
​
    @GetMapping("/user/{id}")
    User findById(@PathVariable("id") Long id);
}

config包

import feign.Logger;
import org.springframework.context.annotation.Bean;
​
public class DefaultFeignConfiguration {
​
    @Bean
    public Logger.Level logLevel() {
        return Logger.Level.BASIC;
    }
}

pojo包

import lombok.Data;
​
@Data
public class User {
    private Long id;
    private String username;
    private String address;
}

在order-service中删除以上相同的类即可

  • 再者,在order-service中引入feign-api的依赖

<!-- 引入Feign的统一API 自己创建的Module-->
<dependency>
    <groupId>cn.itcast.demo</groupId>
    <artifactId>feign-api</artifactId>
    <version>1.0</version>
</dependency>

在引入成功后,重启OrderApplication时会出现以下问题【原因是因为在不同包下,扫描不到】

Description:
​
Field userClient in cn.itcast.order.service.OrderService required a bean of type 'cn.itcast.feign.clients.UserClient' that could not be found.
​
The injection point has the following annotations:
    - @org.springframework.beans.factory.annotation.Autowired(required=true)
​
​
Action:
​
Consider defining a bean of type 'cn.itcast.feign.clients.UserClient' in your configuration.

如何纠正上述问题,如下所示

问题:当定义的FeignClient不在SpringBootApplication的扫描包范围时,这些FeignClient无法使用。有两种方式解决。

方式一:指定FeignClient所在包【加载全部】

@EnableFeignClients(basePackages = "cn.itcast.feign.clients")

方式二:指定FeignClient字节码【推荐,用哪个指定哪个】

@EnableFeignClients(clients = {UserClient.class})

修改结果

import cn.itcast.feign.clients.UserClient;
import cn.itcast.feign.config.DefaultFeignConfiguration;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
​
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
@EnableFeignClients(clients = UserClient.class, defaultConfiguration = DefaultFeignConfiguration.class)
public class OrderApplication {
​
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}
  • 其次,修改order-service中的所有与上述三个组件有关的import部分,改成导入feign-api的包

  • 最后,重启测试

总结【不同包的FeignClient的导入方式】

  • 在@EnableFeignClients注解中添加basePackages,指定FeignClient所在的包

  • 在@EnableFeignClients注解中添加clients,指定具体FeignClient的字节码

7、Gateway网关

7.1、基本介绍

网关功能

  • 身份认证和权限校验

  • 服务路由、负载均衡

  • 请求限流

技术实现

在SpringCloud中网关的实现包括两种

  • gateway

    • SpringCloudGateway是基于Spring 5中提供的WebFlux,属于响应式编程的实现,具备更好的性能。

  • Zuul

    • Zuul是基于Servlet的实现,属于阻塞式编程。

总结【网关的作用】

  • 对用户请求做身份认证、权限校验

  • 将用户请求路由到微服务,并实现负载均衡

  • 对用户请求做限流

7.2、搭建网关服务

搭建网关服务步骤如下:

  • 首先,创建新的module,引入SpringCloudGateway的依赖和nacos的服务发现依赖,并创建启动类。

<!-- 网关依赖 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- nacos服务发现依赖 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
​
@SpringBootApplication
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}
  • 然后,编写路由配置及nacos地址

server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848 # nacos服务地址
    gateway:
      routes:
        # 用户服务
        - id: user-service # 路由标识,必须唯一
          uri: lb://userservice # 路由的目标地址,负载均衡
          predicates: # 路由断言,判断请求是否符合规则
            - Path=/user/** # 路径断言,判断路径是否是以/user开头,如果是则符合
        # 订单服务
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**
  • 最后测试

原理

一段请求进入网关,网关进行路由规则判断,判断后网关从Nacos注册中心拉取服务列表,这里Nacos注册中心进行服务注册和发现,最后通过网关进行负载均衡,发送请求给相应服务。

总结

  • 网关搭建步骤

    • 创建项目,引入nacos服务发现和gateway依赖

    • 配置application.yml,包括服务基本信息、nacos地址、路由

  • 路由配置

    • 路由id:路由的唯一标识

    • 路由目标uri:路由的目标地址,http代表固定地址,lb代表根据服务名负载均衡

    • 路由断言predicates:判断路由的规则,判断请求是否符合要求,符合则转发到路由目的地

    • 路由过滤器filters:对请求或响应做处理

7.3、路由断言工程【Route Predicate Factory】

  • 我们在配置文件中写的断言规则只是字符串,这些字符串会被断言工厂读取并处理,转变为路由判断的条件

  • 例如Path=/user/**是按照路径匹配,这个规则是由org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory类来处理的

  • 像这样的断言工厂在SpringCloudGateway很多

Spring提供了11种基本的Predicate工厂【要用去官网看】

名称说明示例【用过的就填】
After是某个时间点后的请求- After=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Before是某个时间点之前的请求- Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between是某个两个时间点之前的请求
Cookie请求必须包含某些cookie
Header请求必须包含某些header
Host请求必须是访问某个host(域名)
Method请求方式必须指定方式
Path请求路径必须符合指定规则- Path=/user/{segment},/order/**
Query请求参数必须包含指定参数
RemoteAddr请求者的ip必须是指定范围
Weight权重处理

总结

  • PredicateFactory的作用是什么?

    • 读取用户定义的断言条件,对请求做出判断

  • Path=/user/**是什么含义?

    • 路径是以/user开头的就认为是符合的

7.4、路由过滤器【GatewayFilter】

GatewayFilter是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理:

过滤器工厂【GatewayFilterFactory】,Spring提供了31种不同的路由过滤器工厂

名称说明
AddRequestHeader给当前请求添加一个请求头
RemoveRequestHeader移除请求中的一个请求头
AddResponseHeader给响应结果中添加一个响应头
RemoveResponseHeader从响应结果中移除一个响应头
RequestRateLimiter限制请求的流量
...
7.4.1、实践:给所有进入userservicede的请求添加一个请求头

请求头:Truth=ihashiqi

实现方式:首先,在gateway中修改application.yml文件,给userservice的路由添加过滤器:

spring:
  cloud:
    gateway:
      routes: # 网关路由配置
        - id: user-service
          uri: lb://userservice
          predicates:
            - Path=/user/**
          filters: # 过滤器
            - AddRequestHeader=Truth,hashiqi # 添加请求头

然后,在用户控制层方法中加入以下:

/**
 * 路径: /user/110
 * @param id 用户id
 * @return 用户
 */
@GetMapping("/{id}")
public User queryById(@PathVariable("id") Long id, @RequestHeader(value = "Truth", required = false) String truth) {
    System.out.println("Truth:" + truth);
    return userService.queryById(id);
}

最后,控制台输出

Truth:hashiqi

7.4.2、默认过滤器

如果要对所有的路由都生效,则可以将过滤器工厂写到default下,格式如下:

spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848 # nacos服务地址
    gateway:
      routes:
        # 用户服务
        - id: user-service # 路由标识,必须唯一
          uri: lb://userservice # 路由的目标地址,负载均衡
          predicates: # 路由断言,判断请求是否符合规则
            - Path=/user/** # 路径断言,判断路径是否是以/user开头,如果是则符合
#          filters:
#            - AddRequestHeader=Truth,hashiqi
        # 订单服务
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**
            - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
      default-filters:
        - AddRequestHeader=Truth,bomei
7.4.3、总结
  • 过滤器的作用是什么?

    • 对路由的请求或响应做加工处理,比如添加请求头

    • 配置在路由下的过滤器只对当前路由的请求生效

  • defaultFilters的作用是什么?

    • 对所有路由都生效的过滤器

7.5、全局过滤器【GlobalFilter】

全局过滤器的作用也是处理一切进入网关的请求和微服务相应,与GatewayFilter的作用一样。区别在于GatewayFilter通过配置定义,处理逻辑是固定的。而GlobalFilter的逻辑需要自己写代码实现。

定义方式是实现GlobalFilter接口

public interface GlobalFilter {
    
    /**
     * 处理当前请求,有必要的话通过{@link GatewayFilterChain}将请求交给下一个过滤器处理
     *
     * @Param exchange 请求上下文,里面可以获取Request、Response等信息
     * @Param chain 用来把请求委托给下一个过滤器
     * @return {@code Mono<Void>} 返回标示当前过滤器业务结束
     */
    Mono<Void> filter(ServerWebExchange exchange, GtewayFilterChain chain);
    
}
7.5.1、定义全局过滤器,拦截并判断用户身份

需求:定义全局过滤器,拦截请求,判断请求的参数是否满足下面条件

  • 参数中是否有authorization

  • authorization参数值是否为admin

如果同时满足则执行,否则拦截

方式一:自定义类,实现GlobalFilter接口,添加@Order注解

package cn.itcast.gateway;
​
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
​
@Order(-1) // 越小优先级越高
@Component
public class AuthorizeFilter implements GlobalFilter {
​
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1.获取请求参数
        ServerHttpRequest request = exchange.getRequest();
        MultiValueMap<String, String> params = request.getQueryParams();
        // 2.获取参数中的authorization参数
        String auth = params.getFirst("authorization");
        // 3.判断参数值是否等于admin
        if ("admin".equals(auth)) {
            // 4.是,放行
            return chain.filter(exchange);
        }
        // 5.不是,拦截
        // 5.1.设置状态码
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        // 5.2.拦截请求
        return exchange.getResponse().setComplete();
    }
}

方式二:自定义类,实现GlobalFilter、Orderd接口

package cn.itcast.gateway;
​
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
​
//@Order(-1) // 越小优先级越高
@Component
public class AuthorizeFilter implements GlobalFilter, Ordered {
​
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1.获取请求参数
        ServerHttpRequest request = exchange.getRequest();
        MultiValueMap<String, String> params = request.getQueryParams();
        // 2.获取参数中的authorization参数
        String auth = params.getFirst("authorization");
        // 3.判断参数值是否等于admin
        if ("admin".equals(auth)) {
            // 4.是,放行
            return chain.filter(exchange);
        }
        // 5.不是,拦截
        // 5.1.设置状态码
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        // 5.2.拦截请求
        return exchange.getResponse().setComplete();
    }
​
    @Override
    public int getOrder() {
        return -1;
    }
}

如果这时候重启测试时,单单的http://localhost:10010/user/1是访问不到的,只有在后面加上?authorization=admin才可以访问。

7.5.2、总结
  • 全局过滤器的作用是什么?

    • 对所有路由都生效的过滤器,并且可以自定义处理逻辑

  • 实现全局过滤器的步骤?

    • 实现GlobalFilter接口

    • 添加@Order注解或实现Ordered接口

    • 编写处理逻辑

7.6、过滤器执行顺序

请求进入网关会碰到三类过滤器:当前路由的过滤器、DefaultFilter、GlobalFilter

请求路由后,会将当前路由过滤器和DefaultFilter、GlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器。

  • 每一个过滤器都必须指定一个int类型的order值,order值越小,优先级越高,执行顺序越靠前。

  • GlobalFilter通过实现Ordered接口,或者添加@Order注解来指定order值,有自己决定。

  • 路由过滤器和DefaultFilter的order由Spring指定,默认是按照声明顺序从1递增。

  • 当过滤器的order值一样时,会按照DefaultFilter>路由过滤器>GlobalFilter的顺序执行

可以考下面几个类的源码来查看

org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator
#getFilters()方法是先加载defaultFitlters,然后再加载某个route的filters,然后合并
org.springframework.cloud.gateway.handler.FilteringWebHandler
#handle()方法会加载全局过滤器,与前面的过滤器合并后根据order排序,组织过滤器链

总结【过滤器执行顺序】

  • 路由过滤器、DefaultFilter、全局过滤器的执行顺序

    • order值越小,优先级越高

    • 当order值一样时,顺序是DefaultFilter最先,然后是局部的路由过滤器,最后是全局过滤器

7.7、跨域问题处理

跨域:域名不一致就是跨域,主要包括:

跨域问题:浏览器禁止请求的发起者与服务端发生跨域ajax请求,请求被浏览器拦截的问题

解决方案:CORS

网关处理跨域采用的同样是CORS方案,并且只需要简单配置即可实现

spring:
  cloud:
    gateway:
      globalcors: # 全局的跨域处理
        add-to-simple-url-handler-mapping: true # 解决options请求被拦截问题
        corsConfiguration:
          '[/**]': # 拦截一切请求
            allowedOrigins: # 允许哪些网站的跨域请求
              - "http://localhost:8099"
              - "http://www.leyou.com"
            allowedMethods: # 允许的跨域ajax的请求方式
              - "GET"
              - "POST"
              - "DELETE"
              - "PUT"
              - "OPTIONS"
            allowedHeaders: "*" # 允许在请求中携带的头信息
            allowedCredentials: true # 是否允许携带cookie
            maxAge: 360000 # 这次跨域检测的有效期

8、Docker

8.1、Docker认识

项目部署问题:大型项目组件较多,运行环境也较为复杂,部署时会碰到一些问题

  • 依赖关系复杂,容易出现兼容性问题

  • 开发、测试、生产环境有差异

Docker进行解决

  • 将应用的Libs(函数库)、Deps(依赖)、配置与应用一起打包

  • 将每个应用放到一个隔离容器去运行,避免互相干扰

补充

Ubuntu和CentOS都是基于Linux内核,只是系统应用不同,提供的函数库有差异

Docker如何解决不同系统环境的问题

  • Docker将用户程序与所需要调用的系统(CentOS)函数库一起打包

  • Docker运行到不同操作系统时,直接基于打包的库函数,借助于操作系统的Linux内核来运行

总结【Docker是一个快速交付应用、运行应用的技术】

  • 可以将程序及其依赖、运行环境一起打包为一个镜像,可以迁移到任意Linux操作系统

  • 运行时利用沙箱机制形成隔离容器,各个应用互不干扰

  • 启动、移除都可以通过一行命令完成,方便快捷

8.2、Docker和虚拟机

虚拟机(virtual machine)是在操作系统中模拟硬件设备,然后运行另一个操作系统,比如在Windows系统里面运行CentOS系统,这就就可以运行任意的CentOS应用了。

特性Docker虚拟机
性能接近原生性能较差
硬盘占用一般为MB一般为GB
启动秒级分钟级

docker是一个系统进程,而虚拟机是操作系统中的操作系统。

8.3、镜像和容器

镜像(Image):Docker将应用程序及其所需的依赖、函数库、环境、配置等文件打包在一起。

容器(Container):镜像中的应用程序运行后形成的进程就是容器,只是Docker会给容器做隔离,对外不可见。

注:镜像运行起来就是容器,一个镜像可以运行多个容器。

8.3.1、Docker和DockerHub

DockerHub是一个Docker镜像的托管平台,这样的平台称为Docker Registry【比如阿里云、网易云、私有云】。

8.3.2、Docker架构

Docker是一个CS架构的程序,由两部分组成:

  • 服务端(server):Docker守护进程,负责处理Docker指令,管理镜像、容器等。

  • 客户端(client):通过命令或RestAPI向Docker服务端发送命令,可以在本地或远程向服务端发送指令。

8.4、安装Docker

企业部署一般采用Linux操作系统,其中大多数使用CentOS发行版,因此这里演示在CentOS下安装Docker。

看VMware16安装Linux虚拟机.md

8.5、Docker基本操作

8.5.1、镜像相关命令

镜像名称一般分两部分组成:[repository]:[tag]

案例一:拉取镜像

  • 首先,去镜像仓库搜索nignx镜像

  • 然后,根据查看到的镜像名称,拉取自己需要的镜像,通过命令docker pull nginx

  • 最后,通过命令docker images查看拉取到的镜像

案例二:利用docker save将nginx镜像导出磁盘,然再通过load加载回来

  • 首先,利用docker xx --help命令查看docker save和docker load的语法

  • 然后,使用docker tag创建新镜像mynginx1.0

  • 最后,使用docker save导出镜像到磁盘

总结【镜像操作有哪些】

  • docker images

  • docker rmi

  • docker pull

  • docker push

  • docker save

  • docker load

8.5.2、容器相关命令

案例一:创建运行一个nginx容器

  • 首先,去docker hub查看nginx的容器运行命令

docker run --name containerName -p 80:80 -d nginx
// 解读
docker run: 创建并运行一个容器
--name: 给容器起一个名字,比如叫做mn
-p:将宿主机端口与容器端口映射,冒号左侧是宿主机端口,右侧是容器端口
-d:后台运行容器
nginx:镜像名称,例如nginx【这里nginx没有:xxx,是因为这是最新版】

总结

  • docker run命令的常见参数有哪些?

    • --name:指定容器名称

    • -p:指定端口映射

    • -d:让容器后台运行

  • 查看容器日志命令

    • docker logs

    • 添加 -f参数可以持续查看日志

    • ctrl+c可以退出

  • 查看容器状态

    • docker ps

案例二:进入nginx容器,修改HTML文件内容,添加"哈士奇"

  • 首先,进入刚刚创建的nginx容器

docker exec -it mn bash
// 解读
docker exec:进入容器内部,执行一个命令
-it: 给当前进入的容器创建一个标准输入、输出终端,允许我们与容器交互
mn:要进入的容器名称
bash:进入容器后执行的命令,bash是一个linux终端交互命令
  • 然后,进入nginx的HTML所在目录 /usr/share/nginx/html

cd /usr/share/nginx/html
  • 最后,修改index.html的内容

sed -i 's#Welcome to nginx#哈士奇#g' index.html
sed -i 's#<head>#<head><meta charset="utf-8">#g' index.html

总结

  • 查看容器状态

    • docker ps

    • 添加-a参数查看所有状态的容器

  • 删除容器

    • docker rm

    • 不能删除运行中的容器,除非添加-f参数

  • 进入容器

    • 命令是docker exec -it [容器名] [要执行的命令]

    • exec命令可以进入容器修改文件,但是在容器内修改文件是不推荐的

8.6、数据卷

容器与数据耦合问题

  • 不便于修改:当我们要修改nginx的html内容时,需要进入容器内部进行修改,很不方便

  • 数据不可复用:在容器内的修改对外是不可见的,所有修改对新创建的容器是不可复用的

  • 升级维护困难:数据在容器内,如果要升级容器必然删除旧容器,所有数据都跟着删除了

解决方式:数据卷

数据卷(Volume)是一个虚拟目录,指向宿主机文件系统中的某个目录

8.6.1、操作数据卷
docker volunme [COMMAND]

docker volume 命令是数据卷操作,根据命令后跟随的command来确定下一步的操作

命令说明
create创建一个volume
inspect显示一个或多个volume的信息
ls列出所有的volume
prune删除未使用的volume
rm删除一个或多个指定的volume

演示:创建一个数据卷,并查看数据卷在宿主机的目录位置

  • 首先,创建数据卷

docker volume create html
  • 然后,查看所有数据

docker volume ls
  • 最后,查看数据卷详细信息卷

docker volume inspect html

总结

  • 数据卷的作用

    • 将容器与数据分离,解耦合,方便操作容器内数据,保证数据安全

  • 数据卷操作

    • docker volume create xxx

    • docker volume ls

    • docker volume inspect

    • docker volume rm

    • docker volume prune

8.7、挂载数据卷【有点懵】

在创建容器时,可以通过-v参数来挂载一个数据卷到某个容器目录

案例一:创建一个nginx容器,修改容器内的html目录内的index.html内容

需求说明:上个案例中,是进入到nginx容器内容,已经知道nginx的html目录所在位置/usr/share/nginx/html,需要把这个目录挂载到html这个数据卷上,方便操作其中的内容。

步骤:

  • 创建容器并挂载数据卷到容器内的HTML目录

docker run --name mn -p 80:80 -v html:/usr/share/nginx/html -d nginx
  • 进入html数据卷所在位置,并修改HTML内容

# 第一步:查看html数据卷的位置
docker volume inspect html
# 第二步:进入该目录
cd /var/lib/docker/volumes/html/_data
# 第三步:修改文件
vi index.html

总结【数据卷挂载方式】

  • -v volumeName:/targetContainerPath

  • 如果容器运行时volume不存在,会自动被创建出来

案例二:创建并运行一个MYSQL容器,将宿主机目录直接挂载到容器

目录挂载与数据卷挂载的语法相似

  • -v [宿主机目录]:[容器内目录]

  • -v [宿主机文件]:[容器内文件]

实现思路如下:

  • 将mysql.tar文件上传到虚拟机,通过load命令加载为镜像【这里可以直接使用docker进行镜像的拉取】

  • 创建目录 /tmp/mysql/data

  • 创建目录 /tmp/mysql/conf,将hmy.chf文件上传到/tmp/mysql/conf

  • docker创建并运行MYSQL容器

    • 挂载/tmp/mysql/data到mysql容器内数据存储目录

    • 挂载/tmp/mysql/conf/hmy.cnf到mysql容器的配置文件

    • 设置MYSQL密码

总结

  • docker run的命令中通过-v参数挂载文件或目录到容器中

    • -v volume名称:容器内目录

    • -v 宿主机文件:容器内文件

    • -v 宿主机目录:容器内目录

  • 数据卷挂载与目录直接挂载的

    • 数据卷挂载耦合度低,由docker来管理目录,但是目录较深,不好找

    • 目录挂载耦合度高,需要自己管理目录,不过目录容易寻找查看

8.8、Dockerfile自定义镜像

8.8.1、镜像结构

镜像是将应用程序及其需要的系统函数库、环境、配置、依赖打包而成。

  • 基础镜像(BaseImage):应用依赖的系统函数库、环境、配置、文件等

  • 层(Layer):在BaseImage基础上添加安装包、依赖、配置等,每次操作都形成新的一层

  • 入口(Entrypoint):镜像运行入口,一般是程序启动的脚本和参数

8.8.2、什么是Dockerfile

Dockerfile是一个文本文件,其中包含一个个的指令(Instruction),用指令来说明要执行什么操作来构建镜像,每一个指令都形成一层Layer。

指令说明示例
FROM指定基础镜像FROM centos:6
ENV设置环境变量,可在后面指令使用ENV key value
COPY拷贝本地文件到镜像的指定目录COPY ./mysql-5.7.rpm /tmp
RUN执行Linux的shell命令,一般是安装过程的命令RUN yum install gcc
EXPOSE指定容器运行时监听的端口,是给镜像使用者看的EXPOSE 8080
ENTRYPOINT镜像中应用的启动命令,容器运行时调用ENTRYPOINT java -jar xx.jar

案例:基于CentOS镜像构建一个新镜像,运行一个java项目

  • 步骤一:新建一个空文件夹docker-demo

  • 步骤二:docker拉取jdk8镜像

  • 步骤三:运行命令

总结

  • Dockerfile的本质是一个文件,通过指令描述镜像的构建过程

  • Dockerfile的第一行必须是FROM,从一个基础镜像来构建

  • 基础镜像可以是基本操作系统,如CentOS,也可以是其他人制作好的镜像,例如:java:8-alpine

8.9、DockerCompose

基本介绍

  • Docker Compose可以基于Compose文件帮我们快速的部署分布式应用,而无需手动一个个创建和运行容器

  • Compose文件是一个文本文件,通过指令定义集群中的每个容器如何运行

步骤

  • 首先,将下载好的docker-compose上传到虚拟机中的/usr/local/bin/目录

  • 然后,修改文件权限

# 修改权限
chmod +x /usr/local/bin/docker-compose
  • 最后,Base自动补全命令

# 补全命令
curl -L http://raw.githubusercontent.com/docker/compose/1.29.1/contrib/completion/base/docker-compose > /etc/bash_completion.d/docker-compose
# 如果这里出现错误,需要修改自己的hosts文件
echo "199.232.68.133 raw.githubusercontent,com" >> /etc/hosts

8.10、DockerCompose部署微服务集群【先放着】

8.11、Docker镜像仓库【放着】

8.11.1、常见镜像仓库服务

镜像仓库(Docker Registry)有公共和私有两种形式

  • 公共仓库:例如Docker官方的Docker Hub,当然也有网易云镜像服务、DaoCloud镜像服务、阿里云镜像服务等

  • 除了使用公开仓库外,用户可以在本地搭建私有Docker Registry,企业自己的镜像最好采用私有Docker Registry来实现。

9、MQ

9.1、同步通讯和异步通讯

9.1.1、同步通讯

同步调用的问题

微服务间基于Feign的调用就属于同步方式,存在一些问题

支付服务要依次调用订单服务、仓储服务、短信服务等等,会浪费大量的时间

  • 耦合度高

    • 每次加入新的需求,都要修改原来的代码

  • 性能下降

    • 调用者需要等待服务提供者响应,如果调用链过长则响应时间等于每次调用的时间之和

  • 资源浪费

    • 调用链中的每个服务在等待响应过程中,不能释放请求占用的资源,高并发场景下会极度浪费系统资源

  • 级联失败

    • 如果服务提供者出现问题,所有调用方都会跟着出问题,如同多米诺骨牌一样,迅速导致整个微服务群故障

总结

  • 同步调用优点

    • 时效性较强,可以立即得出结果

  • 同步调用缺点

    • 耦合度高

    • 性能和吞吐能力下降

    • 有额外的资源消耗

    • 有级联失败问题

9.1.2、异步通讯

异步调用常见实现就是事件驱动模式

支付服务通过调用Broker再去调用订单服务、仓储服务、短信服务等等

总结

  • 异步通讯优点

    • 服务解耦

    • 性能提升,吞吐量提高

    • 服务没有强依赖,不担心级联失败问题

    • 流量削峰

  • 异步通讯缺点

    • 依赖Broker的可靠性、安全性、吞吐能力

    • 架构复杂,业务没有明显的流水线,不好追踪管理

9.2、MQ基本介绍

MQ(MessageQueue),中文是消息队列,字面来看就是存放消息的队列,也就是事件驱动架构中的Broker。

RabbitMQActiveMQRocketMQKafka
公司/社区RabbitApache阿里Apache
开发语言ErlangJavaJavaScala&Java
协议支持AMQP、XMPP、SMTP、STOMPOpenWire、STOMP、REST、XMPP、AMQP自定义协议自定义协议
可用性一般
单机吞吐量一般非常高
消息延迟微秒级毫秒级毫秒级毫秒以内
消息可靠性一般一般

9.3、RabbitMQ概述及安装

RabbitMQ是基于Erlang语言开发的开源消息通信中间件。

生产者(Publisher)》交换机(exchange)》信道》队列(queue)》消费者(consumer)

安装步骤

  • 方式一:在线拉取

docker pull rabbitmq:3-management
  • 方式二:从本地加载

docker load -i mq.tar
  • 执行下面的命令来运行MQ容器:

docker run \
 -e RABBITMQ_DEFAULT_USER=itcast \
 -e RABBITMQ_DEFAULT_PASS=123321 \
 --name mq \
 --hostname mq1 \
 -p 15672:15672 \
 -p 5672:5672 \
 -d \
 rabbitmq:3-management

总结【RabbitMQ的几个概念】

  • channel:操作MQ的工具

  • exchange:路由消息到队列中

  • queue:缓存消息

  • virtual host:虚拟主机,是对queue、exchange等资源的逻辑分组

9.4、RabbitMQ常见消息模型

MQ的官方文档中给出了5个MQ的Demo实例,对应了几种不同的用法

  • 基本消息队列(BasicQueue)

  • 工作消息队列(WorkQueue)

  • 发布订阅(Publish、Subscribe),又根据交换机类型不同分为三种

    • Fanout Exchange:广播

    • Direct Exchange:路由

    • Topic Exchange:主题

9.4.1、入门案例

官方的HelloWorld是基于最基础的消息队列模型来实现的,只包括三个角色:

发布者(Publisher)》队列(queue)》消费者(consumer)

  • publisher:消息发布者,将消息发送到队列queue

  • queue:消息队列,负责接收并缓存消息

  • consumer:订阅队列,处理队列中的消息

publisher:

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import org.junit.Test;
​
import java.io.IOException;
import java.util.concurrent.TimeoutException;
​
public class PublisherTest {
    @Test
    public void testSendMessage() throws IOException, TimeoutException {
        // 1.建立连接
        ConnectionFactory factory = new ConnectionFactory();
        // 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
        factory.setHost("192.168.176.131");
        factory.setPort(5672);
        factory.setVirtualHost("/");
        factory.setUsername("itcast");
        factory.setPassword("123321");
        // 1.2.建立连接
        Connection connection = factory.newConnection();
​
        // 2.创建通道Channel
        Channel channel = connection.createChannel();
​
        // 3.创建队列
        String queueName = "simple.queue";
        channel.queueDeclare(queueName, false, false, false, null);
​
        // 4.发送消息
        String message = "hello, rabbitmq!";
        channel.basicPublish("", queueName, null, message.getBytes());
        System.out.println("发送消息成功:【" + message + "】");
​
        // 5.关闭通道和连接
        channel.close();
        connection.close();
    }
}

queue:页面

consumer:

import com.rabbitmq.client.*;
​
import java.io.IOException;
import java.util.concurrent.TimeoutException;
​
public class ConsumerTest {
​
    public static void main(String[] args) throws IOException, TimeoutException {
        // 1.建立连接
        ConnectionFactory factory = new ConnectionFactory();
        // 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
        factory.setHost("192.168.176.131");
        factory.setPort(5672);
        factory.setVirtualHost("/");
        factory.setUsername("itcast");
        factory.setPassword("123321");
        // 1.2.建立连接
        Connection connection = factory.newConnection();
​
        // 2.创建通道Channel
        Channel channel = connection.createChannel();
​
        // 3.创建队列
        String queueName = "simple.queue";
        channel.queueDeclare(queueName, false, false, false, null);
​
        // 4.订阅消息
        channel.basicConsume(queueName, true, new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope,
                                       AMQP.BasicProperties properties, byte[] body) throws IOException {
                // 5.处理消息
                String message = new String(body);
                System.out.println("接收到消息:【" + message + "】");
            }
        });
        System.out.println("等待接收消息。。。。");
    }
}

总结

  • 基本消息队列

    • 建立连接

    • 创建channel

    • 利用channel声明队列

    • 利用channel向队列发送消息

  • 基本消息队列的接收流程

    • 建立连接

    • 创建channel

    • 利用channel声明队列

    • 定义consume的消费行为handleDelivery()

    • 利用channel将消费者与队列绑定

9.5、SpringAMQP

9.5.1、基本介绍

网站地址:spring.io/projects/spring-amqp/

  • AMQP即Advanced Message Queuing Protocol,是用于在应用程序或之间传递业务消息的开放标准。该协议与语言和平台无关,更符合微服务中独立性的要求。

  • SpringAMQP是基于AMQP协议定义的一套API规范,提供了模板来发送和接收消息。包含两部分,其中spring-amqp是基础抽象,spring-rabbit是底层的默认实现

  • 特征

    • 侦听器容器,用于异步处理入站消息

    • 用于发送和接收消息的RabbitTemplate

    • RabbitAdmin用于自动声明队列,交换和绑定

9.5.2、入门案例

利用SpringAMQP实现HelloWord中的基础消息队列功能

步骤流程:

  • 首先,在父工程中引入spring-amqp的依赖【publisher和consumer都需要,所以放到父工程mq-demo】

<!--AMQP依赖,包含RabbitMQ-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
  • 然后,在publisher服务中利用RabbitTemplate发送消息到simple.queue队列

    • 在publisher服务中编写application.yml,添加mq连接信息

    spring:
      rabbitmq:
        host: 192.168.176.131 # 主机
        port: 5672 # 端口
        virtual-host: / # 虚拟主机
        username: itcast # 用户名
        password: 123321 # 密码
    • 在publisher服务中建立一个测试类,编写测试方法

    package cn.itcast.mq.spring;
    ​
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.springframework.amqp.rabbit.core.RabbitTemplate;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.test.context.junit4.SpringRunner;
    ​
    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class SpringAmqpTest {
        
        @Autowired
        private RabbitTemplate rabbitTemplate;
        
        @Test
        public void testSimpleQueue() {
            String queueName = "simple.queue";
            String message = "hello, spring amqp!";
            rabbitTemplate.convertAndSend(queueName, message);
        }
    }

总结

问:什么是AMQP?

答:应用间消息通信的一种协议,与语言和平台无关

问:SpringAMQP如何发送消息?

答:(1)引入amqp的starter依赖;(2)配置RabbitMQ地址;(3)利用RabbitTemplate的convertAndSend方法

  • 其次,在consumer服务中编写消费逻辑,绑定simple.queue这个队列

    • 在consumer服务中编写application.yml,添加mq连接信息

    spring:
      rabbitmq:
        host: 192.168.176.131 # 主机名
        port: 5672 # 端口
        virtual-host: / # 虚拟主机
        username: itcast # 用户名
        password: 123321 # 密码
    • 在consumer服务中新建一个类,编写消费逻辑

    @Component
    public class SpringRabbitListener {
        
        @RabbitListener(queues = "simple.queue")
        public void listenSimpleQueueMessage(String msg) throws InterruptedExecption {
            System,out.println("spring 消费者接收到消息:【" + msg + "】");
        }
    }
    • 启动consumer的启动器即可

    消费者接收到simple.queue的消息:【hello, spring maqp!】

总结

问:SpringAMQP如何接收消息?

答:首先,引入amqp的starter依赖;然后,配置RabbitMQ地址;其次,定义类,添加@Component注解;最后,类中声明方法,添加@RabbitListener注解,方法参数接收消息。

注:消息一旦消费就会从队列删除,RabbitMQ没有消息回溯功能。

9.5.3、Work Queue【工作队列】

Work queue,工作队列,可以提高消息处理速度,避免队列消息堆积。

案例:模拟Work Queue,实现一个队列绑定多个消费者

基本思路:

  • 在publisher服务中定义测试方法,每秒产生50条消息,发送到simple.queue

package cn.itcast.mq.spring;
​
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
​
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAmqpTest {
​
    @Autowired
    private RabbitTemplate rabbitTemplate;
​
    @Test
    public void testSendMessage2WorkQueue() throws InterruptedException {
        String queueName = "simple.queue";
        String message = "hello, message__!";
        for (int i = 1; i <= 50; i++) {
            rabbitTemplate.convertAndSend(queueName, message + i);
            Thread.sleep(20);
        }
    }
}
  • 在consumer服务中定义两个消息监听者,都监听simple.queue队列

package cn.itcast.mq.listener;
​
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
​
import java.time.LocalDateTime;
import java.time.LocalTime;
​
@Component
public class SpringRabbitListener {
​
    @RabbitListener(queues = "simple.queue")
    public void listenWorkQueueA(String msg) throws InterruptedException {
        System.out.println("消费者A========接收到simple.queue的消息:【" + msg + "】" + LocalTime.now());
        Thread.sleep(20);
    }
​
    @RabbitListener(queues = "simple.queue")
    public void listenWorkQueueB(String msg) throws InterruptedException {
        System.err.println("消费者B________接收到simple.queue的消息:【" + msg + "】" + LocalTime.now());
        Thread.sleep(200);
    }
}
  • 消费者A每秒处理50条消息,消费者B每秒处理10条消息

  • 最后,进行测试,测试如下:

消费者B________接收到simple.queue的消息:【hello, message__!2】15:23:16.947
消费者A========接收到simple.queue的消息:【hello, message__!1】15:23:16.947
消费者A========接收到simple.queue的消息:【hello, message__!3】15:23:16.970
消费者A========接收到simple.queue的消息:【hello, message__!5】15:23:16.992
消费者A========接收到simple.queue的消息:【hello, message__!7】15:23:17.038
消费者A========接收到simple.queue的消息:【hello, message__!9】15:23:17.083
消费者A========接收到simple.queue的消息:【hello, message__!11】15:23:17.127
消费者B________接收到simple.queue的消息:【hello, message__!4】15:23:17.148
消费者A========接收到simple.queue的消息:【hello, message__!13】15:23:17.173
消费者A========接收到simple.queue的消息:【hello, message__!15】15:23:17.220
消费者A========接收到simple.queue的消息:【hello, message__!17】15:23:17.264
消费者A========接收到simple.queue的消息:【hello, message__!19】15:23:17.312
消费者B________接收到simple.queue的消息:【hello, message__!6】15:23:17.351
消费者A========接收到simple.queue的消息:【hello, message__!21】15:23:17.356
消费者A========接收到simple.queue的消息:【hello, message__!23】15:23:17.401
消费者A========接收到simple.queue的消息:【hello, message__!25】15:23:17.447
消费者A========接收到simple.queue的消息:【hello, message__!27】15:23:17.492
消费者A========接收到simple.queue的消息:【hello, message__!29】15:23:17.538
消费者B________接收到simple.queue的消息:【hello, message__!8】15:23:17.554
消费者A========接收到simple.queue的消息:【hello, message__!31】15:23:17.591
消费者A========接收到simple.queue的消息:【hello, message__!33】15:23:17.631
消费者A========接收到simple.queue的消息:【hello, message__!35】15:23:17.677
消费者A========接收到simple.queue的消息:【hello, message__!37】15:23:17.724
消费者B________接收到simple.queue的消息:【hello, message__!10】15:23:17.757
消费者A========接收到simple.queue的消息:【hello, message__!39】15:23:17.771
消费者A========接收到simple.queue的消息:【hello, message__!41】15:23:17.814
消费者A========接收到simple.queue的消息:【hello, message__!43】15:23:17.859
消费者A========接收到simple.queue的消息:【hello, message__!45】15:23:17.904
消费者A========接收到simple.queue的消息:【hello, message__!47】15:23:17.949
消费者B________接收到simple.queue的消息:【hello, message__!12】15:23:17.960
消费者A========接收到simple.queue的消息:【hello, message__!49】15:23:17.995
消费者B________接收到simple.queue的消息:【hello, message__!14】15:23:18.161
消费者B________接收到simple.queue的消息:【hello, message__!16】15:23:18.364
消费者B________接收到simple.queue的消息:【hello, message__!18】15:23:18.566
消费者B________接收到simple.queue的消息:【hello, message__!20】15:23:18.769
消费者B________接收到simple.queue的消息:【hello, message__!22】15:23:18.973
消费者B________接收到simple.queue的消息:【hello, message__!24】15:23:19.173
消费者B________接收到simple.queue的消息:【hello, message__!26】15:23:19.403
消费者B________接收到simple.queue的消息:【hello, message__!28】15:23:19.606
消费者B________接收到simple.queue的消息:【hello, message__!30】15:23:19.809
消费者B________接收到simple.queue的消息:【hello, message__!32】15:23:20.012
消费者B________接收到simple.queue的消息:【hello, message__!34】15:23:20.216
消费者B________接收到simple.queue的消息:【hello, message__!36】15:23:20.418
消费者B________接收到simple.queue的消息:【hello, message__!38】15:23:20.621
消费者B________接收到simple.queue的消息:【hello, message__!40】15:23:20.823
消费者B________接收到simple.queue的消息:【hello, message__!42】15:23:21.023
消费者B________接收到simple.queue的消息:【hello, message__!44】15:23:21.225
消费者B________接收到simple.queue的消息:【hello, message__!46】15:23:21.428
消费者B________接收到simple.queue的消息:【hello, message__!48】15:23:21.629
消费者B________接收到simple.queue的消息:【hello, message__!50】15:23:21.831

导致这种情况出现是因为如下原因以及处理措施

消费预取限制

修改application.yml文件,设置preFetch这个值,可以控制预取消息的上限

spring:
  rabbitmq:
    host: 192.168.176.131 # 主机名
    port: 5672 # 端口
    virtual-host: / # 虚拟主机
    username: itcast # 用户名
    passowrd: 123321 # 密码
    listener:
      simple:
        prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息

重新启动后结果如下

消费者A========接收到simple.queue的消息:【hello, message__!42】15:35:40.467
消费者A========接收到simple.queue的消息:【hello, message__!43】15:35:40.489
消费者A========接收到simple.queue的消息:【hello, message__!44】15:35:40.511
消费者A========接收到simple.queue的消息:【hello, message__!45】15:35:40.533
消费者A========接收到simple.queue的消息:【hello, message__!46】15:35:40.555
消费者A========接收到simple.queue的消息:【hello, message__!47】15:35:40.576
消费者A========接收到simple.queue的消息:【hello, message__!48】15:35:40.598
消费者B________接收到simple.queue的消息:【hello, message__!49】15:35:40.613
消费者A========接收到simple.queue的消息:【hello, message__!50】15:35:40.633

总结【Work模型的使用】

  • 多个消费者绑定到一个队列,同一条消息只会被一个消费者处理

  • 通过设置prefetch来控制消费者预取的消息数量

9.5.4、发布(Publish)、订阅(Subscribe)

发布订阅模式与之前案例的区别就是允许将同一消息发送给多个消费者。实现方式是加入了exchange(交换机)。

发布者(Publisher)》交换机(exchange)》队列(queue)》消费者(consumer),在发布者和队列之间加入交换机

常见exchange类型包括:

  • Fanout:广播

  • Direct:路由

  • Topic:话题

注意:exchange负责消息路由,而不是存储,路由失败则消息丢失

9.5.4.1、发布订阅-Fanout Exchange

Fanout Exchange会将接收到的消息路由到每一个跟其绑定的queue

实现思路:

首先,在consumer服务中,利用代码声明队列、交换机,并将两者绑定【在consumer服务创建一个类,添加@Configuration注解,并声明FanoutExchange、Queue和绑定关系对象Binding】

package cn.itcast.mq.config;
​
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.FanoutExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
​
@Configuration
public class FanoutConfig {
​
    // itcast.fanout
    @Bean
    public FanoutExchange fanoutExchange() {
        return new FanoutExchange("itcast.fanout");
    }
​
    // fanout.queueA
    @Bean
    public Queue fanoutQueueA() {
        return new Queue("fanout.queueA");
    }
​
    // 绑定队列A到交换机
    @Bean
    public Binding fanoutBindingA(Queue fanoutQueueA, FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(fanoutQueueA).to(fanoutExchange);
    }
​
    // fanout.queueB
    @Bean
    public Queue fanoutQueueB() {
        return new Queue("fanout.queueB");
    }
​
    // 绑定队列B到交换机
    @Bean
    public Binding fanoutBindingB(Queue fanoutQueueB, FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(fanoutQueueB).to(fanoutExchange);
    }
}
  • Declarable【接口】

    • AbstractDeclarable【类】

    • Exchange【接口】

      • AbstractExchange【类】

        • HeadersExchange【类】

        • DirectExchange【类】

        • FanoutExchange【类】

        • TopicExchange【类】

然后,在consumer服务中,编写两个消费者方法,分别监听fanout.queue A 和 fanout.queue B,

package cn.itcast.mq.listener;
​
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
​
import java.time.LocalDateTime;
import java.time.LocalTime;
​
@Component
public class SpringRabbitListener {
​
    @RabbitListener(queues = "fanout.queueA")
    public void listenFanoutQueueA(String msg) {
        System.out.println("消费者接收到fanout.queueA的消息:【" + msg + "】");
    }
​
    @RabbitListener(queues = "fanout.queueB")
    public void listenFanoutQueueB(String msg) {
        System.out.println("消费者接收到fanout.queueB的消息:【" + msg + "】");
    }
}

最后,在publisher中编写测试方法,向itcast.fanout发送消息

@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAmqpTest {
​
    @Autowired
    private RabbitTemplate rabbitTemplate;
​
    @Test
    public void testSendFanoutExchange() {
        // 交换机名称
        String exchangeName = "itcast.fanout";
        // 消息
        String message = "hello, hashiqi";
        // 发送消息
        rabbitTemplate.convertAndSend(exchangeName, "", message);
    }
}

总结

  • 交换机的作用?

    • 接收publisher发送的消息

    • 将消息按照规则路由到与之绑定的队列

    • 不能缓存消息,路由失败,则消息丢失

    • FanoutExchange的会将消息路由到每个绑定的队列

  • 声明队列、交换机、绑定关系的Bean是什么?

    • Queue

    • FanoutExchange

    • Binding

9.5.4.2、发布订阅-DirectExchange

Direct Exchange会将接收的消息根据规则路由到指定的Queue,因此称为路由模式(routes)。

  • 每一个Queue都与Exchange设置一个BindingKey

  • 发送者发送消息时,指定消息的RoutingKey

  • Exchange将消息路由到BindingKey与消息RoutingKey一致的队列

案例:利用SpringAMQP演示DirectExchange的使用

实现思路:

  • 利用@RabbitListener声明Exchange、Queue、RoutingKey

  • 在consumer服务中,编写两个消费者方法,分别监听direct.queueA和direct.queueB

package cn.itcast.mq.listener;
​
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
​
import java.time.LocalDateTime;
import java.time.LocalTime;
​
@Component
public class SpringRabbitListener {
​
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "direct.queueA"),
            exchange = @Exchange(name = "itcast.direct", type = ExchangeTypes.DIRECT),
            key = {"red", "blue"}
    ))
    public void listenDirectQueueA(String msg) {
        System.out.println("消费者接收到direct.queueA的消息:【" + msg + "】");
    }
​
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "direct.queueB"),
            exchange = @Exchange(name = "itcast.direct", type = ExchangeTypes.DIRECT),
            key = {"red", "yellow"}
    ))
    public void listenDirectQueueB(String msg) {
        System.out.println("消费者接收到direct.queueB的消息:【" + msg + "】");
    }
}
  • 在publisher中编写测试方法,向itcast.direct发送消息

package cn.itcast.mq.spring;
​
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
​
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAmqpTest {
​
    @Autowired
    private RabbitTemplate rabbitTemplate;
​
    @Test
    public void testSendFanoutExchange() {
        // 交换机名称
        String exchangeName = "itcast.direct";
        // 消息
        String message = "hello, blue";
        // 发送消息
        rabbitTemplate.convertAndSend(exchangeName, "blue", message);
    }
}

总结

  • 描述下Direct交换机与Fanout交换机的差异?

    • Fanout交换机将消息路由给每一个与之绑定的队列

    • Direct交换机根据RoutingKey判断路由给哪个队列

    • 如果多个队列具有相同的RoutingKey,则与Fanout功能类似

  • 基于@RabbitListener注解声明队列和交换机有哪些常见注解?

    • @Queue

    • @Exchange

9.5.4.3、发布订阅-TopicExchange

TopicExchange与DirectExchange类型,区别在于routingKey必须是多个单词的列表,并且以.分割。

#:代指0个或多个单词

*:代指一个单词

例如:china.news:代表有中国的新闻消息;

案例:利用SpringAMQP演示TopicExchange的使用

实现思路:

  • 在consumer服务中,编写两个消费者方法,分别监听topic.queueA和topic.queueB

  • 并使用@RabbitListener声明Exchange、Queue、RoutingKey

package cn.itcast.mq.listener;
​
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
​
import java.time.LocalDateTime;
import java.time.LocalTime;
​
@Component
public class SpringRabbitListener {
​
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "topic.queueA"),
            exchange = @Exchange(name = "itcast.topic", type = ExchangeTypes.TOPIC),
            key = "china.#"
    ))
    public void listenTopicQueueA(String msg) {
        System.out.println("消费者接收到direct.queueA的消息:【" + msg + "】");
    }
​
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "topic.queueB"),
            exchange = @Exchange(name = "itcast.topic", type = ExchangeTypes.TOPIC),
            key = "#.news"
    ))
    public void listenTopicQueueB(String msg) {
        System.out.println("消费者接收到direct.queueB的消息:【" + msg + "】");
    }
}
  • 在publisher中编写测试方法,向itcast.topic发送消息

package cn.itcast.mq.spring;
​
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
​
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAmqpTest {
​
    @Autowired
    private RabbitTemplate rabbitTemplate;
​
    @Test
    public void testSendFanoutExchange() {
        // 交换机名称
        String exchangeName = "itcast.topic";
        // 消息
        String message = "哈士奇来啦";
        // 发送消息
        rabbitTemplate.convertAndSend(exchangeName, "china.weather", message);
    }
}

总结

  • 描述Direct交换机与Topic交换机差异?

    • Topic是有通配符的

9.5.5、SpringAMQP-消息转换器

案例:测试发送Object类型消息

说明:在SpringAMQP的发送方法中,接收消息的类型是Object,也就是说可以发送任意对象类型的消息,SpringAMQP会帮助实现序列化为字节后发送。

在consumer服务中的config包中创建一个类

package cn.itcast.mq.config;
​
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
​
@Configuration
public class FanoutConfig {
​
    @Bean
    public Queue objectQueue() {
        return new Queue("object.queue");
    }
}

在publisher服务中创建测试类

package cn.itcast.mq.spring;
​
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
​
import java.util.HashMap;
import java.util.Map;
​
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAmqpTest {
​
    @Autowired
    private RabbitTemplate rabbitTemplate;
​
    @Test
    public void testSendObjectQueue() {
        Map<String, Object> msg = new HashMap<>();
        msg.put("name", "留言");
        msg.put("age", 21);
        rabbitTemplate.convertAndSend("object.queue", msg);
    }
}

由上面这样进行测试后发现,消息队列出现的并不是对象,而是一串字节,如果要显示对象,则解决方法如下:

Spring对消息对象的处理是由org.springframework.amqp.support.converter.MessageConverter来处理的。而默认实现是SimpleMessageConverter,基于JDK的ObjectOutputStream完成序列化。

如果要修改只需要定义一个MessageConverter类型的Bean即可。推荐使用JSON方式序列化,步骤如下

  • 在publisher服务引入依赖【因为consumer服务也要用,直接放到父工程】

<!-- 用于消息转换器 -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>
  • 在publisher服务声明MessageConverter,这里是在启动器中声明

@Bean
public MessageConverter messageConverter() {
    return new Jackson2JsonMessageConverter();
}

案例:接收Object类型消息

  • 在consumer服务引入jackson依赖【参照发送消息部分】

  • 在consumer服务定义MessageConverter【参照发送消息部分】

  • 定义一个消费者,监听object.queue队列并消费消息

package cn.itcast.mq.listener;
​
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
​
import java.util.Map;
​
@Component
public class SpringRabbitListener {
​
    @RabbitListener(queues = "object.queue")
    public void listenObjectQueue(Map<String, Object> msg) {
        System.out.println("接收到object.queue的消息:" + msg);
    }
}

总结

  • SpringAMQP中消息的序列化和反序列化是怎么实现的?

    • 使用MessageConverter实现的,默认是JDK的序列化

    • 注意发送方和接收方必须使用相同的MessageConverter

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值