Bootstrap

SpringCloud从入门到精通(超详细文档一)

 引言:初识 Spring Cloud 及应用现状

Spring Cloud 是什么?

在学习本课程之前,读者有必要先了解一下 Spring Cloud。

Spring Cloud 是一系列框架的有序集合,它利用 Spring Boot 的开发便利性简化了分布式系统的开发,比如服务发现、服务网关、服务路由、链路追踪等。Spring Cloud 并不重复造轮子,而是将市面上开发得比较好的模块集成进去,进行封装,从而减少了各模块的开发成本。换句话说:Spring Cloud 提供了构建分布式系统所需的“全家桶”。

Spring Cloud 现状

目前,国内使用 Spring Cloud 技术的公司并不多见,不是因为 Spring Cloud 不好,主要原因有以下几点:

  1. Spring Cloud 中文文档较少,出现问题网上没有太多的解决方案。
  2. 国内创业型公司技术老大大多是阿里系员工,而阿里系多采用 Dubbo 来构建微服务架构。
  3. 大型公司基本都有自己的分布式解决方案,而中小型公司的架构很多用不上微服务,所以没有采用 Spring Cloud 的必要性。

但是,微服务架构是一个趋势,而 Spring Cloud 是微服务解决方案的佼佼者,这也是作者写本系列课程的意义所在。

Spring Cloud 优缺点

其主要优点有:

  1. 集大成者,Spring Cloud 包含了微服务架构的方方面面。
  2. 约定优于配置,基于注解,没有配置文件。
  3. 轻量级组件,Spring Cloud 整合的组件大多比较轻量级,且都是各自领域的佼佼者。
  4. 开发简便,Spring Cloud 对各个组件进行了大量的封装,从而简化了开发。
  5. 开发灵活,Spring Cloud 的组件都是解耦的,开发人员可以灵活按需选择组件。

接下来,我们看下它的缺点:

  1. 项目结构复杂,每一个组件或者每一个服务都需要创建一个项目。
  2. 部署门槛高,项目部署需要配合 Docker 等容器技术进行集群部署,而要想深入了解 Docker,学习成本高。

Spring Cloud 的优势是显而易见的。因此对于想研究微服务架构的同学来说,学习 Spring Cloud 是一个不错的选择。

Spring Cloud 和 Dubbo 对比

Dubbo 只是实现了服务治理,而 Spring Cloud 实现了微服务架构的方方面面,服务治理只是其中的一个方面。下面通过一张图对其进行比较:

这里写图片描述

可以看出,Spring Cloud 比较全面,而 Dubbo 由于只实现了服务治理,需要集成其他模块,需要单独引入,增加了学习成本和集成成本。

Spring Cloud 学习

Spring Cloud 基于 Spring Boot,因此在研究 Spring Cloud 之前,本课程会首先介绍 Spring Boot 的用法,方便后续 Spring Cloud 的学习。

本课程不会讲解 SpringMVC 的用法,因此学习本课程需要读者对 Spring 及 SpringMVC 有过研究。

本课程共分为四个部分:

  • 第一部分初识 Spring Boot,掌握 Spring Boot 基础知识,为后续入门 Spring Cloud 打好基础 。

  • 第二部分 Spring Cloud 入门篇,主要介绍 Spring Cloud 常用模块,包括服务发现、服务注册、配置中心、链路追踪、异常处理等。

  • 第三部分 Spring Cloud 进阶篇,介绍大型分布式系统中事务处理、线程安全等问题,并以一个实例项目手把手教大家搭建完整的微服务系统。

  • 第四部分 Spring Cloud 高级篇,解析 Spring Cloud 源码,并讲解如何部署基于 Spring Cloud 的大型分布式系统。

第01课:Spring Boot 入门

什么是 Spring Boot

Spring Boot 是由 Pivotal 团队提供的基于 Spring 的全新框架,其设计目的是为了简化 Spring 应用的搭建和开发过程。该框架遵循“约定大于配置”原则,采用特定的方式进行配置,从而使开发者无需定义大量的 XML 配置。通过这种方式,Spring Boot 致力于在蓬勃发展的快速应用开发领域成为领导者。

Spring Boot 并不重复造轮子,而且在原有 Spring 的框架基础上封装了一层,并且它集成了一些类库,用于简化开发。换句话说,Spring Boot 就是一个大容器。

下面几张图展示了官网上提供的 Spring Boot 所集成的所有类库:

这里写图片描述

这里写图片描述

这里写图片描述

Spring Boot 官方推荐使用 Maven 或 Gradle 来构建项目,本教程采用 Maven。

第一个 Spring Boot 项目

大多数教程都是以 Hello World 入门,本教程也不例外,接下来,我们就来搭建一个最简单的 Spring Boot 项目。

首先创建一个 Maven 工程,请看下图:

这里写图片描述

然后在 pom.xml 加入 Spring Boot 依赖:

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.1.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

创建一个 Controller 类 HelloController:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@SpringBootApplication
public class HelloController {

@RequestMapping("hello")
String hello() {
return "Hello World!";
}

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

运行 main 方法,Spring Boot 默认会启动自带的 Tomcat 容器,启动成功后,浏览器访问:http://localhost:8080/hello,则会看到下图:

这里写图片描述

我们可以注意到,没有写任何的配置文件,更没有显示的使用任何容器,它是如何启动程序的呢,具体原理我将在第3课中具体分析。

这里我们可以初步分析出,Spring Boot 提供了默认的配置,在启动类里加入 @SpringBootApplication 注解,则这个类就是整个应用程序的启动类。

properties 和 yaml

Spring Boot 整个应用程序只有一个配置文件,那就是 .properties 或 .yml 文件。但是,在前面的示例代码中,我们并没有看到该配置文件,那是因为 Spring Boot 对每个配置项都有默认值。当然,我们也可以添加配置文件,用以覆盖其默认值,这里以 .properties 文件为例,首先在 resources 下新建一个名为 application.properties(注意:文件名必须是 application)的文件,键入内容为:

server.port=8081
server.servlet.context-path=/api

并且启动 main 方法,这时程序请求地址则变成了:http://localhost:8081/api/hello。

Spring Boot 支持 properties 和 yaml 两种格式的文件,文件名分别对应 application.properties 和 application.yml,下面贴出 yaml 文件格式供大家参考:

server:
    port: 8080
    servlet:
        context-path: /api

可以看出 properties 是以逗号隔开,而 yaml 则换行+ tab 隔开,这里需要注意的是冒号后面必须空格,否则会报错。yaml 文件格式更清晰,更易读,这里作者建议大家都采用 yaml 文件来配置。

本教程的所有配置均采用 yaml 文件。

打包、运行

Spring Boot 打包分为 war 和 jar两个格式,下面将分别演示如何构建这两种格式的启动包。

在 pom.xml 加入如下配置:

<packaging>war</packaging>
<build>
<finalName>index</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>2.5</version>
<configuration>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.18.1</version>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
</plugins>
</build>

这个时候运行 mvn package 就会生成 war 包,然后放到 Tomcat 当中就能启动,但是我们单纯这样配置在 Tomcat 是不能成功运行的,会报错,需要通过编码指定 Tomcat 容器启动,修改 HelloController 类:

@RestController
@SpringBootApplication
public class HelloController extends SpringBootServletInitializer{

@RequestMapping("hello")
String hello() {
return "Hello World!";
}

public static void main(String[] args) {
SpringApplication.run(HelloController.class, args);
}
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(Application.class);
}

}

这时再打包放到 Tomcat,启动就不会报错了。

接下来我们继续看如果达成 jar 包,在 pom.xml 加入如下配置:

<packaging>jar</packaging>
<build>
<finalName>api</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<fork>true</fork>
<mainClass>com.lynn.yiyi.Application</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>2.5</version>
<configuration>
<encoding>UTF-8</encoding>
<useDefaultDelimiters>true</useDefaultDelimiters>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.18.1</version>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.3.2</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>

然后通过 mvn package 打包,最后通过 java 命令启动:

java -jar api.jar

这样,最简单的 Spring Boot 就完成了,但是对于一个大型项目,这是远远不够的,Spring Boot 的详细操作可以参照官网

下面展示一个最基础的企业级 Spring Boot 项目的结构:

这里写图片描述

其中,Application.java 是程序的启动类,Startup.java 是程序启动完成前执行的类,WebConfig.java 是配置类,所有 bean 注入、配置、拦截器注入等都放在这个类里面。

第02课:Spring Boot 进阶

上一篇带领大家初步了解了如何使用 Spring Boot 搭建框架,通过 Spring Boot 和传统的 SpringMVC 架构的对比,我们清晰地发现 Spring Boot 的好处,它使我们的代码更加简单,结构更加清晰。

从这一篇开始,我将带领大家更加深入的认识 Spring Boot,将 Spring Boot 涉及到东西进行拆解,从而了解 Spring Boot 的方方面面。学完本文后,读者可以基于 Spring Boot 搭建更加复杂的系统框架。

我们知道,Spring Boot 是一个大容器,它将很多第三方框架都进行了集成,我们在实际项目中用到哪个模块,再引入哪个模块。比如我们项目中的持久化框架用 MyBatis,则在 pom.xml 添加如下依赖:

<dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>1.1.1</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.40</version>
        </dependency>

yaml/properties 文件

我们知道整个 Spring Boot 项目只有一个配置文件,那就是 application.yml,Spring Boot 在启动时,就会从 application.yml 中读取配置信息,并加载到内存中。上一篇我们只是粗略的列举了几个配置项,其实 Spring Boot 的配置项是很多的,本文我们将学习在实际项目中常用的配置项(注:为了方便说明,配置项均以 properties 文件的格式写出,后续的实际配置都会写成 yaml 格式)。

配置项说明举例
server.port应用程序启动端口server.port=8080,定义应用程序启动端口为8080
server.context-path应用程序上下文server.port=/api,则访问地址为:http://ip:port/api
spring.http.multipart.maxFileSize最大文件上传大小,-1为不限制spring.http.multipart.maxFileSize=-1
spring.jpa.database数据库类型spring.jpa.database=MYSQL,指定数据库为mysql
spring.jpa.properties.hibernate.dialecthql方言spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect
spring.datasource.url数据库连接字符串spring.datasource.url=jdbc:mysql://localhost:3306/database?useUnicode=true&characterEncoding=UTF-8&useSSL=true
spring.datasource.username数据库用户名spring.datasource.username=root
spring.datasource.password数据库密码spring.datasource.password=root
spring.datasource.driverClassName数据库驱动spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.jpa.showSql控制台是否打印sql语句spring.jpa.showSql=true

下面是我参与的某个项目的 application.yml 配置文件内容:

server:
  port: 8080
  context-path: /api
  tomcat:
    max-threads: 1000
    min-spare-threads: 50
  connection-timeout: 5000
spring:
  profiles:
    active: dev
  http:
    multipart:
      maxFileSize: -1
  datasource:
    url: jdbc:mysql://localhost:3306/database?useUnicode=true&characterEncoding=UTF-8&useSSL=true
    username: root
    password: root
    driverClassName: com.mysql.jdbc.Driver
  jpa:
    database: MYSQL
    showSql: true
    hibernate:
      namingStrategy: org.hibernate.cfg.ImprovedNamingStrategy
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL5Dialect
mybatis:
  configuration:
     #配置项:开启下划线到驼峰的自动转换. 作用:将数据库字段根据驼峰规则自动注入到对象属性。
     map-underscore-to-camel-case: true

以上列举了常用的配置项,所有配置项信息都可以在官网中找到,本课程就不一一列举了。

多环境配置

在一个企业级系统中,我们可能会遇到这样一个问题:开发时使用开发环境,测试时使用测试环境,上线时使用生产环境。每个环境的配置都可能不一样,比如开发环境的数据库是本地地址,而测试环境的数据库是测试地址。那我们在打包的时候如何生成不同环境的包呢?

这里的解决方案有很多:

  1. 每次编译之前手动把所有配置信息修改成当前运行的环境信息。这种方式导致每次都需要修改,相当麻烦,也容易出错。
  2. 利用 Maven,在 pom.xml 里配置多个环境,每次编译之前将 settings.xml 里面修改成当前要编译的环境 ID。这种方式会事先设置好所有环境,缺点就是每次也需要手动指定环境,如果环境指定错误,发布时是不知道的。
  3. 第三种方案就是本文重点介绍的,也是作者强烈推荐的方式。

首先,创建 application.yml 文件,在里面添加如下内容:

spring:
  profiles:
    active: dev

含义是指定当前项目的默认环境为 dev,即项目启动时如果不指定任何环境,Spring Boot 会自动从 dev 环境文件中读取配置信息。我们可以将不同环境都共同的配置信息写到这个文件中。

然后创建多环境配置文件,文件名的格式为:application-{profile}.yml,其中,{profile} 替换为环境名字,如 application-dev.yml,我们可以在其中添加当前环境的配置信息,如添加数据源:

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/database?useUnicode=true&characterEncoding=UTF-8&useSSL=true
    username: root
    password: root
    driverClassName: com.mysql.jdbc.Driver

这样,我们就实现了多环境的配置,每次编译打包我们无需修改任何东西,编译为 jar 文件后,运行命令:

java -jar api.jar --spring.profiles.active=dev

其中 --spring.profiles.active 就是我们要指定的环境。

常用注解

我们知道,Spring Boot 主要采用注解的方式,在上一篇的入门实例中,我们也用到了一些注解。

本文,我将详细介绍在实际项目中常用的注解。

@SpringBootApplication

我们可以注意到 Spring Boot 支持 main 方法启动,在我们需要启动的主类中加入此注解,告诉 Spring Boot,这个类是程序的入口。如:

@SpringBootApplication
public class Application {

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

如果不加这个注解,程序是无法启动的。

我们查看下 SpringBootApplication 的源码,源码如下:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = {
        @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
        @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

    /**
     * Exclude specific auto-configuration classes such that they will never be applied.
     * @return the classes to exclude
     */
    @AliasFor(annotation = EnableAutoConfiguration.class, attribute = "exclude")
    Class<?>[] exclude() default {};

    /**
     * Exclude specific auto-configuration class names such that they will never be
     * applied.
     * @return the class names to exclude
     * @since 1.3.0
     */
    @AliasFor(annotation = EnableAutoConfiguration.class, attribute = "excludeName")
    String[] excludeName() default {};

    /**
     * Base packages to scan for annotated components. Use {@link #scanBasePackageClasses}
     * for a type-safe alternative to String-based package names.
     * @return base packages to scan
     * @since 1.3.0
     */
    @AliasFor(annotation = ComponentScan.class, attribute = "basePackages")
    String[] scanBasePackages() default {};

    /**
     * Type-safe alternative to {@link #scanBasePackages} for specifying the packages to
     * scan for annotated components. The package of each class specified will be scanned.
     * <p>
     * Consider creating a special no-op marker class or interface in each package that
     * serves no purpose other than being referenced by this attribute.
     * @return base packages to scan
     * @since 1.3.0
     */
    @AliasFor(annotation = ComponentScan.class, attribute = "basePackageClasses")
    Class<?>[] scanBasePackageClasses() default {};

}

在这个注解类上有3个注解,如下:

@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = {
        @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
        @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })

因此,我们可以用这三个注解代替 SpringBootApplication,如:

@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan
public class Application {

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

其中,SpringBootConfiguration 表示 Spring Boot 的配置注解,EnableAutoConfiguration 表示自动配置,ComponentScan 表示 Spring Boot 扫描 Bean 的规则,比如扫描哪些包。

@Configuration

加入了这个注解的类被认为是 Spring Boot 的配置类,我们知道可以在 application.yml 设置一些配置,也可以通过代码设置配置。

如果我们要通过代码设置配置,就必须在这个类上标注 Configuration 注解。如下代码:

@Configuration
public class WebConfig extends WebMvcConfigurationSupport{

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        super.addInterceptors(registry);
        registry.addInterceptor(new ApiInterceptor());
    }
}

不过 Spring Boot 官方推荐 Spring Boot 项目用 SpringBootConfiguration 来代替 Configuration。

@Bean

这个注解是方法级别上的注解,主要添加在 @Configuration 或 @SpringBootConfiguration 注解的类,有时也可以添加在 @Component 注解的类。它的作用是定义一个Bean。

请看下面代码:

    @Bean
    public ApiInterceptor interceptor(){
        return new ApiInterceptor();
    }

那么,我们可以在 ApiInterceptor 里面注入其他 Bean,也可以在其他 Bean 注入这个类。

@Value

通常情况下,我们需要定义一些全局变量,都会想到的方法是定义一个 public static 变量,在需要时调用,是否有其他更好的方案呢?答案是肯定的。下面请看代码:

    @Value("${server.port}")
    String port;
    @RequestMapping("/hello")
    public String home(String name) {
        return "hi "+name+",i am from port:" +port;
    }

其中,server.port 就是我们在 application.yml 里面定义的属性,我们可以自定义任意属性名,通过 @Value 注解就可以将其取出来。

它的好处不言而喻:

  1. 定义在配置文件里,变量发生变化,无需修改代码。
  2. 变量交给Spring来管理,性能更好。

注: 本课程默认针对于对 SpringMVC 有所了解的读者,Spring Boot 本身基于 Spring 开发的,因此,本文不再讲解其他 Spring 的注解。

注入任何类

本节通过一个实际的例子来讲解如何注入一个普通类,并且说明这样做的好处。

假设一个需求是这样的:项目要求使用阿里云的 OSS 进行文件上传。

我们知道,一个项目一般会分为开发环境、测试环境和生产环境。OSS 文件上传一般有如下几个参数:appKey、appSecret、bucket、endpoint 等。不同环境的参数都可能不一样,这样便于区分。按照传统的做法,我们在代码里设置这些参数,这样做的话,每次发布不同的环境包都需要手动修改代码。

这个时候,我们就可以考虑将这些参数定义到配置文件里面,通过前面提到的 @Value 注解取出来,再通过 @Bean 将其定义为一个 Bean,这时我们只需要在需要使用的地方注入该 Bean 即可。

首先在 application.yml 加入如下内容:

appKey: 1
appSecret: 1
bucket: lynn
endPoint: https://www.aliyun.com

其次创建一个普通类:

public class Aliyun {

    private String appKey;

    private String appSecret;

    private String bucket;

    private String endPoint;

    public static class Builder{

        private String appKey;

        private String appSecret;

        private String bucket;

        private String endPoint;

        public Builder setAppKey(String appKey){
            this.appKey = appKey;
            return this;
        }

        public Builder setAppSecret(String appSecret){
            this.appSecret = appSecret;
            return this;
        }

        public Builder setBucket(String bucket){
            this.bucket = bucket;
            return this;
        }

        public Builder setEndPoint(String endPoint){
            this.endPoint = endPoint;
            return this;
        }

        public Aliyun build(){
            return new Aliyun(this);
        }
    }

    public static Builder options(){
        return new Aliyun.Builder();
    }

    private Aliyun(Builder builder){
        this.appKey = builder.appKey;
        this.appSecret = builder.appSecret;
        this.bucket = builder.bucket;
        this.endPoint = builder.endPoint;
    }

    public String getAppKey() {
        return appKey;
    }

    public String getAppSecret() {
        return appSecret;
    }

    public String getBucket() {
        return bucket;
    }

    public String getEndPoint() {
        return endPoint;
    }
}

然后在 @SpringBootConfiguration 注解的类添加如下代码:

@Value("${appKey}")
    private String appKey;
    @Value("${appSecret}")
    private String appSecret;
    @Value("${bucket}")
    private String bucket;
    @Value("${endPoint}")
    private String endPoint;

    @Bean
    public Aliyun aliyun(){
        return Aliyun.options()
                .setAppKey(appKey)
                .setAppSecret(appSecret)
                .setBucket(bucket)
                .setEndPoint(endPoint)
                .build();
    }

最后在需要的地方注入这个 Bean 即可:

    @Autowired
    private Aliyun aliyun;

拦截器

我们在提供 API 的时候,经常需要对 API 进行统一的拦截,比如进行接口的安全性校验。

本节,我会讲解 Spring Boot 是如何进行拦截器设置的,请看接下来的代码。

创建一个拦截器类:ApiInterceptor,并实现 HandlerInterceptor 接口:

public class ApiInterceptor implements HandlerInterceptor {
    //请求之前
    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
        System.out.println("进入拦截器");
        return true;
    }
    //请求时
    @Override
    public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {

    }
    //请求完成
    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {

    }
}

@SpringBootConfiguration 注解的类继承 WebMvcConfigurationSupport 类,并重写 addInterceptors 方法,将 ApiInterceptor 拦截器类添加进去,代码如下:

@SpringBootConfiguration
public class WebConfig extends WebMvcConfigurationSupport{

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        super.addInterceptors(registry);
        registry.addInterceptor(new ApiInterceptor());
    }
}

异常处理

我们在 Controller 里提供接口,通常需要捕捉异常,并进行友好提示,否则一旦出错,界面上就会显示报错信息,给用户一种不好的体验。最简单的做法就是每个方法都使用 try catch 进行捕捉,报错后,则在 catch 里面设置友好的报错提示。如果方法很多,每个都需要 try catch,代码会显得臃肿,写起来也比较麻烦。

我们可不可以提供一个公共的入口进行统一的异常处理呢?当然可以。方法很多,这里我们通过 Spring 的 AOP 特性就可以很方便的实现异常的统一处理。

@Aspect
@Component
public class WebExceptionAspect {

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

//凡是注解了RequestMapping的方法都被拦截   @Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
    private void webPointcut() {
    }

    /**
     * 拦截web层异常,记录异常日志,并返回友好信息到前端 目前只拦截Exception,是否要拦截Error需再做考虑
     *
     * @param e
     *            异常对象
     */
    @AfterThrowing(pointcut = "webPointcut()", throwing = "e")
    public void handleThrowing(Exception e) {
        e.printStackTrace();
        logger.error("发现异常!" + e.getMessage());
        logger.error(JSON.toJSONString(e.getStackTrace()));
        //这里输入友好性信息
        writeContent("出现异常");
    }

    /**
     * 将内容输出到浏览器
     *
     * @param content
     *            输出内容
     */
    private void writeContent(String content) {
        HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
                .getResponse();
        response.reset();
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Content-Type", "text/plain;charset=UTF-8");
        response.setHeader("icop-content-type", "exception");
        PrintWriter writer = null;
        try {
            writer = response.getWriter();
        } catch (IOException e) {
            e.printStackTrace();
        }
        writer.print(content);
        writer.flush();
        writer.close();
    }
}

这样,我们无需每个方法都添加 try catch,一旦报错,则会执行 handleThrowing 方法。

优雅的输入合法性校验

为了接口的健壮性,我们通常除了客户端进行输入合法性校验外,在 Controller 的方法里,我们也需要对参数进行合法性校验,传统的做法是每个方法的参数都做一遍判断,这种方式和上一节讲的异常处理一个道理,不太优雅,也不易维护。

其实,SpringMVC 提供了验证接口,下面请看代码:

@GetMapping("authorize")
public void authorize(@Valid AuthorizeIn authorize, BindingResult ret){
    if(result.hasFieldErrors()){
            List<FieldError> errorList = result.getFieldErrors();
            //通过断言抛出参数不合法的异常
            errorList.stream().forEach(item -> Assert.isTrue(false,item.getDefaultMessage()));
        }
}
public class AuthorizeIn extends BaseModel{

    @NotBlank(message = "缺少response_type参数")
    private String responseType;
    @NotBlank(message = "缺少client_id参数")
    private String ClientId;

    private String state;

    @NotBlank(message = "缺少redirect_uri参数")
    private String redirectUri;

    public String getResponseType() {
        return responseType;
    }

    public void setResponseType(String responseType) {
        this.responseType = responseType;
    }

    public String getClientId() {
        return ClientId;
    }

    public void setClientId(String clientId) {
        ClientId = clientId;
    }

    public String getState() {
        return state;
    }

    public void setState(String state) {
        this.state = state;
    }

    public String getRedirectUri() {
        return redirectUri;
    }

    public void setRedirectUri(String redirectUri) {
        this.redirectUri = redirectUri;
    }
}

在 controller 的方法需要校验的参数后面必须跟 BindingResult,否则无法进行校验。但是这样会抛出异常,对用户而言不太友好!

那怎么办呢?

很简单,我们可以利用上一节讲的异常处理,对报错进行拦截:

@Component
@Aspect
public class WebExceptionAspect implements ThrowsAdvice{

    public static final Logger logger = LoggerFactory.getLogger(WebExceptionAspect.class);

//拦截被GetMapping注解的方法    @Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")
    private void webPointcut() {
    }

    @AfterThrowing(pointcut = "webPointcut()",throwing = "e")
    public void afterThrowing(Exception e) throws Throwable {
        logger.debug("exception 来了!");
        if(StringUtils.isNotBlank(e.getMessage())){
                           writeContent(e.getMessage());
        }else{
            writeContent("参数错误!");
        }

    }

    /**
     * 将内容输出到浏览器
     *
     * @param content
     *            输出内容
     */
    private void writeContent(String content) {
        HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
                .getResponse();
        response.reset();
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Content-Type", "text/plain;charset=UTF-8");
        response.setHeader("icop-content-type", "exception");
        PrintWriter writer = null;
        try {
            writer = response.getWriter();

            writer.print((content == null) ? "" : content);
            writer.flush();
            writer.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这样当我们传入不合法的参数时就会进入 WebExceptionAspect 类,从而输出友好参数。

我们再把验证的代码单独封装成方法:

protected void validate(BindingResult result){
        if(result.hasFieldErrors()){
            List<FieldError> errorList = result.getFieldErrors();
            errorList.stream().forEach(item -> Assert.isTrue(false,item.getDefaultMessage()));
        }
    }

这样每次参数校验只需要调用 validate 方法就行了,我们可以看到代码的可读性也大大的提高了。

接口版本控制

一个系统上线后会不断迭代更新,需求也会不断变化,有可能接口的参数也会发生变化,如果在原有的参数上直接修改,可能会影响线上系统的正常运行,这时我们就需要设置不同的版本,这样即使参数发生变化,由于老版本没有变化,因此不会影响上线系统的运行。

一般我们可以在地址上带上版本号,也可以在参数上带上版本号,还可以再 header 里带上版本号,这里我们在地址上带上版本号,大致的地址如:http://api.example.com/v1/test,其中,v1 即代表的是版本号。具体做法请看代码:

@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
public @interface ApiVersion {

    /**
     * 标识版本号
     * @return
     */
    int value();
}
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {

    // 路径中版本的前缀, 这里用 /v[1-9]/的形式
    private final static Pattern VERSION_PREFIX_PATTERN = Pattern.compile("v(\\d+)/");

    private int apiVersion;

    public ApiVersionCondition(int apiVersion){
        this.apiVersion = apiVersion;
    }

    @Override
    public ApiVersionCondition combine(ApiVersionCondition other) {
        // 采用最后定义优先原则,则方法上的定义覆盖类上面的定义
        return new ApiVersionCondition(other.getApiVersion());
    }

    @Override
    public ApiVersionCondition getMatchingCondition(HttpServletRequest request) {
        Matcher m = VERSION_PREFIX_PATTERN.matcher(request.getRequestURI());
        if(m.find()){
            Integer version = Integer.valueOf(m.group(1));
            if(version >= this.apiVersion)
            {
                return this;
            }
        }
        return null;
    }

    @Override
    public int compareTo(ApiVersionCondition other, HttpServletRequest request) {
        // 优先匹配最新的版本号
        return other.getApiVersion() - this.apiVersion;
    }

    public int getApiVersion() {
        return apiVersion;
    }
}
public class CustomRequestMappingHandlerMapping extends
        RequestMappingHandlerMapping {

    @Override
    protected RequestCondition<ApiVersionCondition> getCustomTypeCondition(Class<?> handlerType) {
        ApiVersion apiVersion = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
        return createCondition(apiVersion);
    }

    @Override
    protected RequestCondition<ApiVersionCondition> getCustomMethodCondition(Method method) {
        ApiVersion apiVersion = AnnotationUtils.findAnnotation(method, ApiVersion.class);
        return createCondition(apiVersion);
    }

    private RequestCondition<ApiVersionCondition> createCondition(ApiVersion apiVersion) {
        return apiVersion == null ? null : new ApiVersionCondition(apiVersion.value());
    }
}
@SpringBootConfiguration
public class WebConfig extends WebMvcConfigurationSupport {

    @Bean
    public AuthInterceptor interceptor(){
        return new AuthInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuthInterceptor());
    }

    @Override
    @Bean
    public RequestMappingHandlerMapping requestMappingHandlerMapping() {
        RequestMappingHandlerMapping handlerMapping = new CustomRequestMappingHandlerMapping();
        handlerMapping.setOrder(0);
        handlerMapping.setInterceptors(getInterceptors());
        return handlerMapping;
    }
}

Controller 类的接口定义如下:

@ApiVersion(1)
@RequestMapping("{version}/dd")
public class HelloController{}

这样我们就实现了版本控制,如果增加了一个版本,则创建一个新的 Controller,方法名一致,ApiVersion 设置为2,则地址中 v1 会找到 ApiVersion 为1的方法,v2 会找到 ApiVersion 为2的方法。

自定义 JSON 解析

Spring Boot 中 RestController 返回的字符串默认使用 Jackson 引擎,它也提供了工厂类,我们可以自定义 JSON 引擎,本节实例我们将 JSON 引擎替换为 fastJSON,首先需要引入 fastJSON:

<dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>${fastjson.version}</version>
        </dependency>

其次,在 WebConfig 类重写 configureMessageConverters 方法:

@Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        super.configureMessageConverters(converters);
        /*
        1.需要先定义一个convert转换消息的对象;
        2.添加fastjson的配置信息,比如是否要格式化返回的json数据
        3.在convert中添加配置信息
        4.将convert添加到converters中
         */
        //1.定义一个convert转换消息对象
        FastJsonHttpMessageConverter fastConverter=new FastJsonHttpMessageConverter();
        //2.添加fastjson的配置信息,比如:是否要格式化返回json数据
        FastJsonConfig fastJsonConfig=new FastJsonConfig();
        fastJsonConfig.setSerializerFeatures(
                SerializerFeature.PrettyFormat
        );
        fastConverter.setFastJsonConfig(fastJsonConfig);
        converters.add(fastConverter);
    }

单元测试

Spring Boot 的单元测试很简单,直接看代码:

@SpringBootTest(classes = Application.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class TestDB {

    @Test
    public void test(){
    }
}

模板引擎

在传统的 SpringMVC 架构中,我们一般将 JSP、HTML 页面放到 webapps 目录下面,但是 Spring Boot 没有 webapps,更没有 web.xml,如果我们要写界面的话,该如何做呢?

Spring Boot 官方提供了几种模板引擎:FreeMarker、Velocity、Thymeleaf、Groovy、mustache、JSP。

这里以 FreeMarker 为例讲解 Spring Boot 的使用。

首先引入 FreeMarker 依赖:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-freemarker</artifactId>
    </dependency>

在 resources 下面建立两个目录:static 和 templates,如图所示:

这里写图片描述

其中 static 目录用于存放静态资源,譬如:CSS、JS、HTML 等,templates 目录存放模板引擎文件,我们可以在 templates 下面创建一个文件:index.ftl(freemarker 默认后缀为 .ftl),并添加内容:

<!DOCTYPE html>
<html>
    <head>

    </head>
    <body>
        <h1>Hello World!</h1>
    </body>
</html>

然后创建 PageController 并添加内容:

@Controller
public class PageController {

    @RequestMapping("index.html")
    public String index(){
        return "index";
    }
}

启动 Application.java,访问:http://localhost:8080/index.html,就可以看到如图所示:

这里写图片描述

第03课:Spring Boot 启动原理

引言

Spring Boot 大大简化了我们的开发配置,节省了大量的时间,确实比较方便。但是对于新手来说,如果不了解个中原理,难免会遇到坑。

本文作者将带领大家走近神秘的 Spring Boot,一步步破开它的神秘面纱,探索 Spring Boot 的启动原理。

开发任何基于 Spring Boot 的项目,我们都会使用以下的启动类:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

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

可以看到,Application 类中定义了注解 @SpringBootApplication,main 方法里通过 SpringApplication.run 来启动整个应用程序。因此要研究 Spring Boot 的启动原理,我们就需要从这两个地方入手。

强大的 SpringBootApplication

首先,我们先来看看 SpringBootApplication 源码是怎么定义这个注解的:

/**
 * Indicates a {@link Configuration configuration} class that declares one or more
 * {@link Bean @Bean} methods and also triggers {@link EnableAutoConfiguration
 * auto-configuration} and {@link ComponentScan component scanning}. This is a convenience
 * annotation that is equivalent to declaring {@code @Configuration},
 * {@code @EnableAutoConfiguration} and {@code @ComponentScan}.
 *
 * @author Phillip Webb
 * @author Stephane Nicoll
 * @since 1.2.0
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = {
        @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
        @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

    /**
     * Exclude specific auto-configuration classes such that they will never be applied.
     * @return the classes to exclude
     */
    @AliasFor(annotation = EnableAutoConfiguration.class, attribute = "exclude")
    Class<?>[] exclude() default {};

    /**
     * Exclude specific auto-configuration class names such that they will never be
     * applied.
     * @return the class names to exclude
     * @since 1.3.0
     */
    @AliasFor(annotation = EnableAutoConfiguration.class, attribute = "excludeName")
    String[] excludeName() default {};

    /**
     * Base packages to scan for annotated components. Use {@link #scanBasePackageClasses}
     * for a type-safe alternative to String-based package names.
     * @return base packages to scan
     * @since 1.3.0
     */
    @AliasFor(annotation = ComponentScan.class, attribute = "basePackages")
    String[] scanBasePackages() default {};

    /**
     * Type-safe alternative to {@link #scanBasePackages} for specifying the packages to
     * scan for annotated components. The package of each class specified will be scanned.
     * <p>
     * Consider creating a special no-op marker class or interface in each package that
     * serves no purpose other than being referenced by this attribute.
     * @return base packages to scan
     * @since 1.3.0
     */
    @AliasFor(annotation = ComponentScan.class, attribute = "basePackageClasses")
    Class<?>[] scanBasePackageClasses() default {};

}

可以看到,除了最基础的注解外,还增加了三个 @SpringBootConfiguration@EnableAutoConfiguration@ComponentScan。因此,正如上一篇所讲的一样,我们将 SpringBootApplication 替换成这三个注解也是相同的效果:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;

@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan
public class Application {

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

每次我们都写这三个注解比较麻烦,因此我们只写 @SpringBootApplication 就行了。

下面,我们分别来介绍这三个注解。

SpringBootConfiguration

我们先来看看它的源码:

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Configuration;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration
public @interface SpringBootConfiguration {
}

它其实就是一个 Configuration,但是 Spring Boot 推荐用 SpringBootConfiguration 来代替 Configuration。

Spring Boot 社区推荐使用 JavaConfig 配置,所以要用到 @Configuration

我们先来看看 SpringMVC 基于 XML 是如何配置的:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd"
       default-lazy-init="true">
    <!--bean定义-->
</beans>

而 JavaConfig 的配置是这样的:

import org.springframework.boot.SpringBootConfiguration;

@SpringBootConfiguration
public class WebConfig {
    //bean定义
}

任何标注了 SpringBootConfiguration 或 Configuration 的类都是一个 JavaConfig。

我们再来看看基于 XML 的 Bean 是如何定义的:

<bean id="service" class="ServiceImpl">

</bean>

而 JavaConfig 的配置是这样的:

import org.springframework.boot.SpringBootConfiguration;

@SpringBootConfiguration
public class WebConfig {
    //bean定义
    @Bean
    public Service service(){
        return new ServiceImpl();
    }
}

任何标注了 Bean 的方法都被定义为一个 Bean,我们可以在任何 Spring 的 IoC 容器中注入进去。

EnableAutoConfiguration

这个注解尤为重要,它的作用是自动将 JavaConfig 中的 Bean 装载到 IoC 容器中。

ComponentScan

这个注解的作用是自动扫描并加载符合条件的组件(如:Component、Bean 等),我们可以通过 basePakcages 来指定其扫描的范围,如果不指定,则默认从标注了 @ComponentScan 注解的类所在包开始扫描。如下代码:

@ComponentScan(basePackages = "com.lynn")

因此,Spring Boot 的启动类最好放在 root package 下面,因为默认不指定 basePackages,这样能保证扫描到所有包。

以上只是从表面来研究 Spring Boot 的启动原理,那么,为什么通过 SpringBootApplication 和 SpringApplication.run() 就能启动一个应用程序,它的底层到底是怎么实现的呢?别急,我们马上来一探究竟。

源码解析

我们知道,启动类先调用了 SpringApplication 的静态方法 run,跟踪进去后发现,它会先实例化 SpringApplication,然后调用 run 方法。

/**
     * Static helper that can be used to run a {@link SpringApplication} from the
     * specified sources using default settings and user supplied arguments.
     * @param sources the sources to load
     * @param args the application arguments (usually passed from a Java main method)
     * @return the running {@link ApplicationContext}
     */
    public static ConfigurableApplicationContext run(Object[] sources, String[] args) {
        return new SpringApplication(sources).run(args);
    }

所以,要分析它的启动源码,首先要分析 SpringApplicaiton 的构造过程。

SpringApplication 构造器

在 SpringApplication 构造函数内部,它会调用内部的一个定义为 private 的方法 initialize:

public SpringApplication(Object... sources) {
    initialize(sources);
}

private void initialize(Object[] sources) {
        if (sources != null && sources.length > 0) {
            this.sources.addAll(Arrays.asList(sources));
        }
        this.webEnvironment = deduceWebEnvironment();
        setInitializers((Collection) getSpringFactoriesInstances(
                ApplicationContextInitializer.class));
        setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class));
        this.mainApplicationClass = deduceMainApplicationClass();
    }

通过上述代码,我们分析到 SpringApplication 实例化时有以下几个步骤:

1.将所有 sources 加入到全局 sources 中,目前只有一个 Application。

2.判断是否为 Web 程序(javax.servlet.Servlet、org.springframework.web.context.ConfigurableWebApplicationContext 这两个类必须存在于类加载器中)。

判断过程可以参看以下源码:

private static final String[] WEB_ENVIRONMENT_CLASSES = new String[]{"javax.servlet.Servlet", "org.springframework.web.context.ConfigurableWebApplicationContext"};
private boolean deduceWebEnvironment() {
        for (String className : WEB_ENVIRONMENT_CLASSES) {
            if (!ClassUtils.isPresent(className, null)) {
                return false;
            }
        }
        return true;
    }

3.设置应用程序初始化器 ApplicationContextInitializer,做一些初始化的工作。

4.设置应用程序事件监听器 ApplicationListener。

5.找出启动类,设置到 mainApplicationClass 中。

SpringApplication 的执行流程

SpringApplication 构造完成后,就会调用 run 方法,这时才真正的开始应用程序的执行。

先来看看源码:

public ConfigurableApplicationContext run(String... args) {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        ConfigurableApplicationContext context = null;
        FailureAnalyzers analyzers = null;
        configureHeadlessProperty();
        SpringApplicationRunListeners listeners = getRunListeners(args);
        listeners.starting();
        try {
            ApplicationArguments applicationArguments = new DefaultApplicationArguments(
                    args);
            ConfigurableEnvironment environment = prepareEnvironment(listeners,
                    applicationArguments);
            Banner printedBanner = printBanner(environment);
            context = createApplicationContext();
            analyzers = new FailureAnalyzers(context);
            prepareContext(context, environment, listeners, applicationArguments,
                    printedBanner);
            refreshContext(context);
            afterRefresh(context, applicationArguments);
            listeners.finished(context, null);
            stopWatch.stop();
            if (this.logStartupInfo) {
                new StartupInfoLogger(this.mainApplicationClass)
                        .logStarted(getApplicationLog(), stopWatch);
            }
            return context;
        }
        catch (Throwable ex) {
            handleRunFailure(context, listeners, analyzers, ex);
            throw new IllegalStateException(ex);
        }
    }

通过上述源码,将执行流程分解如下:

  1. 初始化 StopWatch,调用其 start 方法开始计时。
  2. 调用 configureHeadlessProperty 设置系统属性 java.awt.headless,这里设置为 true,表示运行在服务器端,在没有显示器和鼠标键盘的模式下工作,模拟输入输出设备功能。
  3. 遍历 SpringApplicationRunListeners 并调用 starting 方法。
  4. 创建一个 DefaultApplicationArguments 对象,它持有 args 参数,就是 main 函数传进来的参数调用 prepareEnvironment 方法。
  5. 打印 banner。
  6. 创建 Spring Boot 上下文。
  7. 初始化 FailureAnalyzers。
  8. 调用 prepareContext。
  9. 调用 AbstractApplicationContext 的 refresh 方法,并注册钩子。
  10. 在容器完成刷新后,依次调用注册的 Runners。
  11. 调用 SpringApplicationRunListeners 的 finished 方法。
  12. 启动完成并停止计时。
  13. 初始化过程中出现异常时调用 handleRunFailure 进行处理,然后抛出 IllegalStateException 异常。

第04课:初识 Spring Cloud

Spring Cloud 基于 Spring Boot,因此在前几篇,我们系统地学习了 Spring Boot 的基础知识,为深入研究Spring Cloud打下扎实的基础。

从本章开始,我们将正式进入探索Spring Cloud秘密的旅程中。学习完本课程后,读者将从中学习到如何搭建一个完整的分布式架构,从而向架构师方向靠近。

微服务概述

根据百度百科的描述,微服务架构是一项在云中部署应用和服务的新技术。大部分围绕微服务的争论都集中在容器或其他技术是否能很好的实施微服务,而红帽说 API 应该是重点。

微服务可以在“自己的程序”中运行,并通过“轻量级设备与 HTTP 型 API 进行沟通”。关键在于该服务可以在自己的程序中运行。通过这一点我们就可以将服务公开与微服务架构(在现有系统中分布一个 API)区分开来。在服务公开中,许多服务都可以被内部独立进程所限制。如果其中任何一个服务需要增加某种功能,那么就必须缩小进程范围。在微服务架构中,只需要在特定的某种服务中增加所需功能,而不影响整体进程。

微服务的核心是 API,在一个大型系统中,我们可以将其拆分为一个个的子模块,每一个模块就可以是一个服务,各服务之间通过 API 进行通信。

什么是 Spring Cloud

Spring Cloud是微服务架构思想的一个具体实现,它为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理、服务发、断路器,智能路由、微代理、控制总线等)。

Spring Cloud 基于 Spring Boot 框架,它不重复造轮子,而是将第三方实现的微服务应用的一些模块集成进去。准确的说,Spring Cloud 是一个容器。

最简单的 Spring Cloud 项目

学习任何一门语言和框架,从 Hello World 入门是最合适的,Spring Cloud 也不例外,接下来,我们就来实现一个最简单的 Spring Cloud 项目。

最简单的 Spring Cloud 微服务架构包括服务发现和服务提供者(即一个大型系统拆分出来的子模块),最极端的微服务可以做到一个方法就是一个服务,一个方法就是一个项目。在一个系统中,服务怎么拆分,要具体问题具体分析,也取决于系统的并发性、高可用性等因素。

闲话少说,请看代码。

首先是服务发现,这里我们采用 Eureka。

pom.xml 文件添加如下内容:

<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.9.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
<dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Dalston.SR5</version>
                <type>pom</type>
                <scope>import</scope>
                <exclusions>
                </exclusions>
            </dependency>
        </dependencies>
    </dependencyManagement>

增加 application.yml 文件,添加如下内容:

server:
  port: 8761
spring:
  profiles:
    active: dev
eureka:
  server:
    enable-self-preservation: false
  instance:
    preferIpAddress: true
    hostname: ${spring.cloud.client.ipAddress}
    instanceId: ${spring.cloud.client.ipAddress}:${server.port}
  client:
    registerWithEureka: false
    fetchRegistry: false
    serviceUrl:
      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/

添加一个启动类 Application.java:

@SpringBootApplication
@EnableEurekaServer
public class Application {

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

然后再创建一个项目,实现服务提供者,在 pom.xml 添加如下内容:

<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.9.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
<dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Dalston.SR5</version>
                <type>pom</type>
                <scope>import</scope>
                <exclusions>
                </exclusions>
            </dependency>
        </dependencies>
    </dependencyManagement>

增加 application.yml,并增加如下内容:

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 8762
spring:
  application:
    name: hello

增加一个启动类:

@SpringBootApplication
@EnableEurekaClient
@RestController
public class HelloController {

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

    @Value("${server.port}")
    String port;
    @RequestMapping("/hello")
    public String home(String name) {
        return "hi "+name+",i am from port:" +port;
    }

}

这时,分别启动服务发现和服务提供者,浏览器输入:http://localhost:8761,即服务发现的地址:

这里写图片描述

可以发现,服务提供者 Hello 已经注册到服务发现中了,然后我们请求 hello 接口地址:http://localhost:8762/hello?name=lynn,即可以看到下面返回数据:

这里写图片描述

以上只是 Spring Cloud 的入门实例,是为了给大家展示什么是 Spring Cloud,如果要深入研究它,就必须学习本文之后的课程。在后面的课程中,我将各个模块逐步拆解,一个一个给大家详细讲解。

第05课:服务注册与发现

我们知道,微服务是一个架构思想,而 Spring Cloud 集成了用以实现微服务架构的方方面面。从本文开始,我将带领大家逐个击破 Spring Cloud 的各个模块。

本文,我们先来学习服务的注册与发现,Spring Cloud Netflix 的 Eureka 组件是服务于发现模块,下面我们将学习它。

服务注册与发现模块分为服务注册中心和服务提供者,接下来,我将一一讲解。

服务注册中心

首先,创建一个 Maven 主工程,主工程的 pom.xml 添加如下内容:

<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.9.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Dalston.SR5</version>
                <type>pom</type>
                <scope>import</scope>
                <exclusions>
                </exclusions>
            </dependency>
        </dependencies>
    </dependencyManagement>

接着,在主工程基础上创建两个 module:一个 module 为服务注册中心,一个 module 为服务提供者(即客户端)。

下面将详细演示如何创建服务注册中心。

1.右键工程 -> New -> Module,如下图所示:

这里写图片描述

2.选择 next,输入 moudle 名,如下图所示:

这里写图片描述

这里写图片描述

3.点击 next -> finish,如下图所示:

这里写图片描述

4.然后在 pom.xml 添加依赖:

<dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka-server</artifactId>
        </dependency>
    </dependencies>

创建启动类 Application.java:

@SpringBootApplication
@EnableEurekaServer
public class Application {

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

这里,我们注意到除了前面提到的 @SpringBootApplication外,这个类还增加了一个注解:EnableEurekaServer,这个注解的作用就是标注该应用程序是一个注册中心,只是添加这个注解还不够,还需要增加配置。

在 resources 下面创建 application.yml 并添加如下内容:

server:
  port: 8761
eureka:
  server:
    enable-self-preservation: false
  instance:
    preferIpAddress: true
    hostname: ${spring.cloud.client.ipAddress}
    instanceId: ${spring.cloud.client.ipAddress}:${server.port}
  client:
    registerWithEureka: false
    fetchRegistry: false
    serviceUrl:
      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/

启动该应用程序,打开浏览器并访问:http://localhost:8761。如果看到如下界面,说明注册中心已经启动起来了:

这里写图片描述

下面说明一下注册中心各个配置项的含义:

  • eureka.server.enable-self-preservation:是否开启自我保护,默认为 true,在开启自我保护的情况下,注册中心在丢失客户端时,会进入自动保护模式,注册中心并不会将该服务从注册中心删除掉。这里我设置为 false,即关闭自我保护。根据我的经验,如果设置为 true,在负载均衡条件下,一个服务挂掉后,注册中心并没有删掉该服务,会导致客户端请求的时候可能会请求到该服务,导致系统无法访问,所以我推荐将这个属性设置为 false。
  • eureka.instance.preferIpAddress:是否以 IP 注册到注册中心,Eureka 默认是以 hostname 来注册的。
  • client.serviceUrl.defaultZone:注册中心默认地址。

建议读者按照以上的配置项写就行了。

服务提供者

我们有了注册中心,那么就可以创建一个服务提供者(即客户端)注册到注册中心去了。

同样地,按照注册中心的创建方式,创建一个 module,并且在 pom.xml 添加如下内容:

<dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
    </dependencies>

然后创建 Application.java:

@SpringBootApplication
@EnableEurekaClient
public class Application {

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

这里用到了一个注解:EnableEurekaClient,标注了此注解,说明该项目是一个服务提供者。

然后创建配置文件 application.yml:

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 8762
spring:
  application:
    name: eurekaclient

其中,spring.application.name 为该服务的名字,eureka.client.serviceUrl.defaultZone 的作用是指定注册中心的地址。

然后启动该工程,重新访问:http://localhost:8761,即可看到如下界面:

这里写图片描述

我们可以看到,刚刚创建的服务提供者 eurekaclient 已经被注册到注册中心了。

第06课:服务网关

本文,我们将学习 Spring Cloud的另一个组件:zuul,它提供微服务的网关功能,即中转站,通过它提供的接口,可以转发不同的服务。在学习 zuul 之前,我们先接着上一篇的代码,来看看服务提供者是如何提供服务的。

在服务提供者的 module 下创建 HelloController 类,添加内容如下:

@RestController
public class HelloController {

    @RequestMapping("index")
    public String index(){
        return "Hello World!";
    }
}

然后分别启动服务注册中心和服务提供者,浏览器输入:http://localhost:8762/index,即可看见如下画面:

这里写图片描述

在实际的项目中,一个项目可能会包含很多个服务,每个服务的端口和 IP 都可能不一样。那么,如果我们以这种形式提供接口给外部调用,代价是非常大的。从安全性上考虑,系统对外提供的接口应该进行合法性校验,防止非法请求,如果按照这种形式,那每个服务都要写一遍校验规则,维护起来也很麻烦。

这个时候,我们需要统一的入口,接口地址全部由该入口进入,而服务只部署在局域网内供这个统一的入口调用,这个入口就是我们通常说的服务网关。

Spring Cloud 给我们提供了这样一个解决方案,那就是 zuul,它的作用就是进行路由转发、异常处理和过滤拦截。下面,我将演示如果使用 zuul 创建一个服务网关。

创建 gateway 工程

在父项目上右键 -> New -> Module,创建一个名为 gateway 的工程,在其 pom.xml 中,加入如下依赖:

<dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-zuul</artifactId>
        </dependency>
    </dependencies>

创建 Application 启动类,并增加 @EnableZuulProxy 注解:

@SpringBootApplication
@EnableEurekaClient
@EnableZuulProxy
public class Application {

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

最后添加 application.yml 配置文件,内容如下:

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 8080
spring:
  application:
    name: gateway
zuul:
  routes:
    api:
      path: /api/**
      serviceId: eurekaclient

我们可以看到,服务网关的配置多了几项,具体含义如下。

  • zuul.routes.api.path:指定请求基础地址,其中 API 可以是任何字符。

  • serviceId:转发到的服务 ID,也就是指定服务的 application.name,上述实例的含义表示只要包含 /api/ 的地址,都自动转发到 eurekaclient 的服务去。

然后我们启动服务注册中心、服务提供者、服务网关,访问地址:http://localhost:8080/api/index,我们可以看到和之前的界面完全一样。其实只要引入了 zuul,它就会自动帮我们实现反向代理和负载均衡。配置文件中的地址转发其实就是一个反向代理,那它如何实现负载均衡呢?

我们修改服务提供者的 Controller 如下:

RestController
public class HelloController {

    @Value("${server.port}")
    private int port;

    @RequestMapping("index")
    public String index(){
        return "Hello World!,端口:"+port;
    }
}

重新启动。然后再修改服务提供者的端口为8673,再次启动它(切记:原先启动的不要停止),访问地址:http://localhost:8761,我们可以看到 eurekaclient 服务有两个地址:

这里写图片描述

再不断访问地址:http://localhost:8080/api/index,可以看到交替出现以下界面:

这里写图片描述

这里写图片描述

由此可以得出,当一个服务启动多个端口时,zuul 服务网关会依次请求不同端口,以达到负载均衡的目的。

服务拦截

前面我们提到,服务网关还有个作用就是接口的安全性校验,这个时候我们就需要通过 zuul 进行统一拦截,zuul 通过继承过滤器 ZuulFilter 进行处理,下面请看具体用法。

新建一个类 ApiFilter 并继承 ZuulFilter:

@Component
public class ApiFilter extends ZuulFilter {

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

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

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

    @Override
    public Object run() {
        //这里写校验代码
        return null;
    }
}

其中:

  • filterType 为过滤类型,可选值有 pre(路由之前)、routing(路由之时)、post(路由之后)、error(发生错误时调用)。
  • filterOrdery 为过滤的顺序,如果有多个过滤器,则数字越小越先执行
  • shouldFilter 表示是否过滤,这里可以做逻辑判断,true 为过滤,false 不过滤
  • run 为过滤器执行的具体逻辑,在这里可以做很多事情,比如:权限判断、合法性校验等。

下面,我们来做一个简单的安全验证:

@Override
    public Object run() {
        //这里写校验代码
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        String token = request.getParameter("token");
        if(!"12345".equals(token)){
            context.setSendZuulResponse(false);
            context.setResponseStatusCode(401);
            try {
                context.getResponse().getWriter().write("token is invalid.");
            }catch (Exception e){}
        }
        return null;
    }

启动 gateway,在浏览器输入地址:http://localhost:8080/api/index,可以看到以下界面:

这里写图片描述

再通过浏览器输入地址:http://localhost:8080/api/index?token=12345,可以看到以下界面:

这里写图片描述

错误拦截

在一个大型系统中,服务是部署在不同的服务器下面的,我们难免会遇到某一个服务挂掉或者请求不到的时候,如果不做任何处理,服务网关请求不到会抛出500错误,对用户是不友好的。

我们为了提供用户的友好性,需要返回友好性提示,zuul 为我们提供了一个名叫 ZuulFallbackProvider 的接口,通过它我们就可以对这些请求不到的服务进行错误处理。

新建一个类 ApiFallbackProvider 并且实现 ZuulFallbackProvider 接口:

Component
public class ApiFallbackProvider implements ZuulFallbackProvider{

    @Override
    public String getRoute() {
        return "eurekaclient";
    }

    @Override
    public ClientHttpResponse fallbackResponse() {
        return new ClientHttpResponse() {
            @Override
            public HttpStatus getStatusCode() throws IOException {
                return HttpStatus.OK;
            }

            @Override
            public int getRawStatusCode() throws IOException {
                return 200;
            }

            @Override
            public String getStatusText() throws IOException {
                return "{code:0,message:\"服务器异常!\"}";
            }

            @Override
            public void close() {

            }

            @Override
            public InputStream getBody() throws IOException {
                return new ByteArrayInputStream(getStatusText().getBytes());
            }

            @Override
            public HttpHeaders getHeaders() {
                HttpHeaders headers = new HttpHeaders();
                headers.setContentType(MediaType.APPLICATION_JSON);
                return headers;
            }
        };
    }

其中,getRoute 方法返回要处理错误的服务名,fallbackResponse 方法返回错误的处理规则。

现在开始测试这部分代码,首先停掉服务提供者 eurekaclient,再重启 gateway,请求地址:http://localhost:8080/api/index?token=12345,即可出现以下界面:

这里写图片描述

第07课:服务消费者

前面我们提到,对外提供接口通过 zuul 服务网关实现。一个大型的系统由多个微服务模块组成,各模块之间不可避免需要进行通信,一般我们可以通过内部接口调用的形式,服务 A 提供一个接口,服务 B 通过 HTTP 请求调用服务 A 的接口,为了简化开发,Spring Cloud 提供了一个基础组件方便不同服务之间的 HTTP 调用,那就是 Feign。

什么是 Feign

Feign 是一个声明式的 HTTP 客户端,它简化了 HTTP 客户端的开发。使用 Feign,只需要创建一个接口并注解,就能很轻松的调用各服务提供的 HTTP 接口。Feign 默认集成了 Ribbon,默认实现了负载均衡。

创建 Feign 服务

在根项目上创建一个 module,命名为 feign,然后在 pom.xml 添加如下内容:

<dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-feign</artifactId>
        </dependency>
    </dependencies>

创建 application.yml,内容如下:

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 8081
spring:
  application:
    name: feign

最后创建一个启动类 Application:

@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
public class Application {

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

}

我们可以看到启动类增加了一个新的注解:@EnableFeignClients,如果我们要使用 Feign 声明式 HTTP 客户端,必须要在启动类加入这个注解,以开启 Feign。

这样,我们的 Feign 就已经集成完成了,那么如何通过 Feign 去调用之前我们写的 HTTP 接口呢?请看下面的做法。

首先创建一个接口 ApiService,并且通过注解配置要调用的服务地址:

@FeignClient(value = "eurekaclient")
public interface ApiService {

    @RequestMapping(value = "/index",method = RequestMethod.GET)
    String index();
}

分别启动注册中心 EurekaServer、服务提供者EurekaClient(这里服务提供者启动两次,端口分别为8762、8763,以观察 Feign 的负载均衡效果)。

然后在 Feign 里面通过单元测试来查看效果。

1.添加单元测试依赖。

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

2.添加测试代码。

@SpringBootTest(classes = Application.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class TestDB {

    @Autowired
    private ApiService apiService;

    @Test
    public void test(){
        try {
            System.out.println(apiService.index());
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

最后分别启动两次单元测试类,我们可以发现控制台分别打印如下信息:

Hello World!,端口:8762
Hello World!,端口:8763

由此可见,我们成功调用了服务提供者提供的接口,并且循环调用不同的接口,说明它自带了负载均衡效果。

第08课:服务异常处理

上一篇,我们讲了服务之间的相互通信,利用 Feign 的声明式 HTTP 客户端,通过注解的形式很容易做到不同服务之间的相互调用。

我们的服务最终是部署在服务器上,因为各种原因,服务难免会发生故障,那么其他服务去调用这个服务就会调不到,甚至会一直卡在那里,导致用户体验不好。针对这个问题,我们就需要对服务接口做错误处理,一旦发现无法访问服务,则立即返回并报错,我们捕捉到这个异常就可以以可读化的字符串返回到前端。

为了解决这个问题,业界提出了熔断器模型。

Hystrix 组件

SpringCloud 集成了 Netflix 开源的 Hystrix 组件,该组件实现了熔断器模型,它使得我们很方便地实现熔断器。

在实际项目中,一个请求调用多个服务是比较常见的,如果较底层的服务发生故障将会发生连锁反应。这对于一个大型项目是灾难性的。因此,我们需要利用 Hystrix 组件,当特定的服务不可用达到一个阈值(Hystrix 默认5秒20次),将打开熔断器,即可避免发生连锁反应。

代码实现

紧接上一篇的代码,Feign 是默认自带熔断器的,在 D 版本 SpringCloud 中是默认关闭的,我们可以在 application.yml 中开启它:

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 8081
spring:
  application:
    name: feign
#开启熔断器
feign:
  hystrix:
    enabled: true

新建一个类 ApiServiceError.java 并实现 ApiService:

@Component
public class ApiServiceError implements ApiService {

    @Override
    public String index() {
        return "服务发生故障!";
    }
}

然后在 ApiService 的注解中指定 fallback:

@FeignClient(value = "eurekaclient",fallback = ApiServiceError.class)
public interface ApiService {

    @RequestMapping(value = "/index",method = RequestMethod.GET)
    String index();
}

再创建 Controller 类:ApiController,加入如下代码:

@RestController
public class ApiController {

    @Autowired
    private ApiService apiService;

    @RequestMapping("index")
    public String index(){
        return apiService.index();
    }
}

测试熔断器

分别启动注册中心 EurekaServer、服务提供者 EurekaClient 和服务消费者 Feign,然后访问:http://localhost:8081/index,可以看到顺利请求到接口:

enter image description here然后停止 EurekaClient,再次请求,可以看到熔断器生效了:

enter image description here

熔断器监控

Hystrix 给我们提供了一个强大的功能,那就是 Dashboard。Dashboard 是一个 Web 界面,它可以让我们监控 Hystrix Command 的响应时间、请求成功率等数据。

下面我们开始改造 Feign 工程,在 Feign 工程的 pom.xml 下加入依赖:

<dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-hystrix</artifactId>
        </dependency>
        <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>

这三个依赖缺一不可,否则会有意想不到的事情发生。

然后在启动类 Application.java 中加入 @EnableHystrixDashboard@EnableCircuitBreaker 注解:

@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
@EnableHystrixDashboard
@EnableCircuitBreaker
public class Application {

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

}

然后分别启动 EurekaServer、EurekaClient 和 Feign 并访问:http://localhost:8081/hystrix,可以看到如下画面:

enter image description here

按照上图箭头所示,输入相关信息后,点击 Monitor Stream 按钮进入下一界面,打开新窗口访问:http://localhost:8081/index,在 Dashboard 界面即可看到 Hystrix 监控界面:

enter image description here

Hystrix 熔断器的基本用法就介绍到这里。前面我们创建了注册中心、服务提供者、服务消费者、服务网关和熔断器,每个工程都有配置文件,而且有些配置是想通的,按照这个方式进行应用程序的配置,维护性较差,扩展性也较差,比如很多个服务都会配置数据源,而数据源只有一个,那么如果我们的数据源地址发生变化,所有地方都需要改,如何改进这个问题呢?下一篇所讲解的配置中心就是为解决这个问题而生的,敬请期待。

第09课:配置中心

通过前面章节,我们已经学习了 SpringCloud 的很多组件,每个组件都创建了一个工程,而每个工程都会有一个配置文件,并且有些配置是一样的。例如:在实际项目中,我们创建了用户和订单两个服务,这两个服务是同一个数据库,那么我们在这两个服务的配置文件都会配置相同的数据源,一旦我们的数据库地址发生改变(只是一种情况),用户和订单两个服务的配置文件都需要改,这还是只是两个服务,在一个大型系统(比如淘宝),将会有成千上万个服务,按照这种方式代价无疑是巨大的。

不过无需担心,正所谓上有政策,下有对策,既然有这个问题,就一定会有解决方案,那就是创建一个配置中心,专门用于管理系统的所有配置,也就是我们将所有配置文件放到统一的地方进行管理。

我们知道,SpringCloud 就是为了简化开发而生的,因此 SpringCloud 为我们集成了配置中心——Spring Cloud Config 组件。

Spring Cloud Config 简介

Spring Cloud Config 是一个高可用的分布式配置中心,它支持将配置存放到内存(本地),也支持将其放到 Git 仓库进行统一管理(本文主要探讨和 Git 的融合)。

创建配置中心

创建配置中心一般分为以下几个步骤:

1.创建 Git 仓库。

本文为了演示实例,已经创建好了用于存放配置文件的 Git 仓库,点击这里访问。

2.创建配置中心。

在原有工程创建一个 moudle,命名为 config,在 pom.xml 加入配置中心的依赖:

<dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-config-server</artifactId>
        </dependency>

创建启动类 Application.java:

@SpringBootApplication
@EnableEurekaClient
@EnableConfigServer
public class Application {

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

注意,要加入 @EnableConfigServer 注解,否则配置中心是无法开启的。

创建 application.yml 并增加如下内容:

server:
  port: 8888
spring:
  application:
    name: config
  profiles:
    active: dev
  cloud:
    config:
      server:
        git:
          uri: https://github.com/lynnlovemin/SpringCloudLesson.git #配置git仓库地址
          searchPaths: 第09课/config #配置仓库路径
          username: ****** #访问git仓库的用户名
          password: ****** #访问git仓库的用户密码
      label: master #配置仓库的分支
eureka:
  instance:
    hostname: ${spring.cloud.client.ipAddress}
    instanceId: ${spring.cloud.client.ipAddress}:${server.port}
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/

注意这里出现了前面课程没有出现过的新配置: eureka.instance.hostname 和 eureka.instance.instanceId,我们可以通过一个测试来看这两个配置的作用。

首先分别启动注册中心 eurekaserver 和配置中心 config,浏览器访问:http://localhost:8761,我们可以看到如下界面:

enter image description here

可以看到箭头所指向的位置是以 IP:端口形式呈现的,现在我们去掉这两个配置重新启动配置中心 config,再次访问:http://localhost:8761,可以看到:

enter image description here

由此可见,它默认是以 ip:application_name:端口呈现的。

在实际项目中,建议大家都写成上述配置,否则如果通过 K8S 或 Docker 部署系统,可能会出现问题,具体原因将在第16课提到。

通过上述过程,配置服务中心已经创建完成,启动它并且访问地址:http://localhost:8888/config/dev,即可看到:

enter image description here

3.修改各个服务配置。

我们创建配置中心的目的就是为了方便其他服务进行统一的配置管理,因此,还需要修改各个服务。

以服务提供者 eurekaclient 为例,按照以下步骤进行操作。

在 pom.xml 加入配置中心依赖:

<dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-config</artifactId>
        </dependency>

在 resources 下新建 bootstrap.yml 并删除 application.yml(注意:这里不是 application.yml,而是 bootstrap.yml):

spring:
  application:
    name: eurekaclient
  profiles:
    active: dev
  cloud:
    config:
      profile: dev #指定配置环境,配置文件如果是多环境则取名类似:config-dev.yml
      name: eurekaclient #指定配置文件名字(多个配置文件以英文逗号隔开)
      label: master #git仓库分支名
      discovery:
        enabled: true
        serviceId: config #连接的配置中心名字(applicaiton.name)
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/

在配置中心配置的 Git 仓库相应路径下创建配置文件 eurekaclient.yml(本实例为第09课/config):

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 8763
spring:
  application:
    name: eurekaclient

我们依次启动注册中心、配置中心和服务提供者 eurekaclient,可以看到 eurekaclient 的监听端口为8763,然后修改 eurekaclient.yml 的 server.port 为8764,重新启动 eurekaclient,可以看到其监听端口为8764,说明 eurekaclient 成功从 Git 上拉取了配置。

配置自动刷新

我们注意到,每次修改配置都需要重新启动服务,配置才会生效,这种做法也比较麻烦,因此我们需要一个机制,每次修改了配置文件,各个服务配置自动生效,Spring Cloud 给我们提供了解决方案。

手动刷新配置

我们先来看看如何通过手动方式刷新配置。

1.在 eurekaclient 工程的 pom.xml 添加依赖:

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

2.修改远程 Git 仓库的配置文件 eurekaclient.yml:

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 8764
spring:
  application:
    name: eurekaclient
management:
  security:
    #关闭安全验证,否则访问refresh端点时会提示权限不足
    enabled: false

3.在 HelloController 类加入 @RefeshScope 依赖:

@RestController
@RefreshScope
public class HelloController {

    @Value("${server.port}")
    private int port;

    @RequestMapping("index")
    public String index(){
        return "Hello World!,端口:"+port;
    }
}

以上步骤就集成了手动刷新配置。下面开始进行测试。

  1. 依次启动注册中心,配置中心,客户端;
  2. 访问地址:http://localhost:8763/index,即可看到:enter image description here
  3. 修改 Git 仓库远程配置文件 eurekaclient.yml 的端口为8764;
  4. 重新访问2的地址,我们发现端口未发生改变;
  5. POST 方式请求地址:http://localhost:8763/refresh,如:curl -X POST http://localhost:8763/refresh,可以的客户端控制台看到如下日志信息:enter image description here说明 refresh 端点已请求配置中心刷新配置。 6.再次访问2的地址,可以看到:enter image description here我们发现端口已发生改变,说明刷新成功!

自动刷新配置

前面我们讲了通过 /refresh 端点手动刷新配置,如果每个微服务的配置都需要我们手动刷新,代价无疑是巨大的。不仅如此,随着系统的不断扩张,维护也越来越麻烦。因此,我们有必要实现自动刷新配置。

自动刷新配置原理

  1. 利用 Git 仓库的 WebHook,可以设置当有内容 Push 上去后,则通过 HTTP 的 POST 远程请求指定地址。
  2. 利用消息队列如 RabbitMQ、Kafka 等自动通知到每个微服务(本文以 RabbitMQ 为例讲解)。

实现步骤

下面我们就来实现自动刷新配置。 1.安装 RabbitMQ(安装步骤省略,请自行百度) 2.在 eurekaclient 加入如下依赖:

<dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-bus-amqp</artifactId>
        </dependency>

3.在 bootstrap.yml 添加以下内容:

spring:
    rabbitmq:
        host: localhost
        port: 5672
        username: guest
        password: guest

4.启动注册中心、配置中心和客户端;

5.POST 方式请求:http://localhost:8763/bus/refresh,可以看到配置已被刷新,实际项目中,我们会单独创建一个工程用以刷新配置,请求这个地址后,可以发现所有加入了 RefreshScope 和 actuator 依赖的工程都会被刷新配置。

6.利用 Git 的 WebHook,实现自动刷新,如图:

enter image description here

设置好刷新 URL 后,点击提交。以后每次有新的内容被提交后,会自动请求该 URL 实现配置的自动刷新。

第10课:消息总线

其实在上一课我们已经接触过了消息总线,那就是 Spring Cloud Bus,这一课我们将继续深入研究 Spring Cloud Bus 的一些特性。

局部刷新

Spring Cloud Bus 用于实现在集群中传播一些状态变化(例如:配置变化),它常常与 Spring Cloud Config 联合实现热部署。上一课我们体验了配置的自动刷新,但每次都会刷新所有微服务,有些时候我们只想刷新部分微服务的配置,这时就需要通过 /bus/refresh 断点的 destination 参数来定位要刷新的应用程序。

它的基本用法如下:

/bus/refresh?destination=application:port

其中,application 为各微服务指定的名字,port 为端口,如果我们要刷新所有指定微服务名字下的配置,则 destination 可以设置为 application:例如:/bus/refresh/destination=eurekaclient:,代表刷新所有名字为 EurekaClient 的微服务配置。

改进架构

在前面的示例中,我们是通过某一个微服务的 /bus/refesh 断点来实现配置刷新,但是这种方式并不优雅,它有以下弊端:

  1. 破坏了微服务的单一职责原则,微服务客户端理论上应只关注自身业务,而不应该负责配置刷新。
  2. 破坏了微服务各节点的对等性。
  3. 有一定的局限性。在微服务迁移时,网络地址时常会发生改变,这时若想自动刷新配置,就不得不修改 Git 仓库的 WebHook 配置。

因此,我们应考虑改进架构,将 ConfigServer 也加入到消息总线来,将其 /bus/refresh 用于实现配置的自动刷新。这样,各个微服务节点就只需关注自身业务,无需再承担配置自动刷新的任务(具体代码已上传到 Github 上,此时不再列举)。

我们来看看此时的架构图:

enter image description here

注意: 所有需要刷新配置的服务都需要添加以下依赖。

<dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-bus-amqp</artifactId>
        </dependency>

并且需要在配置文件设置 rabbitmq 信息:

 spring:
    rabbitmq:
        host: localhost
        port: 5672
        username: guest
        password: guest

消息总线事件

在某些场景下,我们需要知道 Spring Cloud Bus 的事件传播细节,这时就需要跟踪消息总线事件。

要实现跟踪消息总线事件是一件很容易的事情,只需要修改配置文件,如下所示:

server:
  port: 8888
spring:
  application:
    name: config
  profiles:
    active: dev
  cloud:
    bus:
      trace:
        enable: true
    config:
      server:
        git:
          uri: https://github.com/lynnlovemin/SpringCloudLesson.git #配置git仓库地址
          searchPaths: 第09课/config #配置仓库路径
          username: lynnlovemin #访问git仓库的用户名
          password: liyi880301 #访问git仓库的用户密码
      label: master #配置仓库的分支
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
eureka:
  instance:
    hostname: ${spring.cloud.client.ipAddress}
    instanceId: ${spring.cloud.client.ipAddress}:${server.port}
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
management:
  security:
    enabled: false

我们将 spring.cloud.trace.enabled 设置为 true 即可,这样我们在 POST 请求 /bus/refresh 后,浏览器访问访问 /trace 端点即可看到如下数据:

[{
    "timestamp": 1527299528556,
    "info": {
        "method": "GET",
        "path": "/eurekaclient/dev/master",
        "headers": {
            "request": {
                "accept": "application/json, application/*+json",
                "user-agent": "Java/1.8.0_40",
                "host": "192.168.31.218:8888",
                "connection": "keep-alive"
            },
            "response": {
                "X-Application-Context": "config:dev:8888",
                "Content-Type": "application/json;charset=UTF-8",
                "Transfer-Encoding": "chunked",
                "Date": "Sat, 26 May 2018 01:52:08 GMT",
                "status": "200"
            }
        },
        "timeTaken": "4200"
    }
}, {
    "timestamp": 1527299524802,
    "info": {
        "method": "POST",
        "path": "/bus/refresh",
        "headers": {
            "request": {
                "host": "localhost:8888",
                "user-agent": "curl/7.54.0",
                "accept": "*/*"
            },
            "response": {
                "X-Application-Context": "config:dev:8888",
                "status": "200"
            }
        },
        "timeTaken": "1081"
    }
}, {
    "timestamp": 1527299497470,
    "info": {
        "method": "GET",
        "path": "/eurekaclient/dev/master",
        "headers": {
            "request": {
                "accept": "application/json, application/*+json",
                "user-agent": "Java/1.8.0_40",
                "host": "192.168.31.218:8888",
                "connection": "keep-alive"
            },
            "response": {
                "X-Application-Context": "config:dev:8888",
                "Content-Type": "application/json;charset=UTF-8",
                "Transfer-Encoding": "chunked",
                "Date": "Sat, 26 May 2018 01:51:37 GMT",
                "status": "200"
            }
        },
        "timeTaken": "2103"
    }
}, {
    "timestamp": 1527299490374,
    "info": {
        "method": "GET",
        "path": "/eurekaclient/dev/master",
        "headers": {
            "request": {
                "accept": "application/json, application/*+json",
                "user-agent": "Java/1.8.0_40",
                "host": "192.168.31.218:8888",
                "connection": "keep-alive"
            },
            "response": {
                "X-Application-Context": "config:dev:8888",
                "Content-Type": "application/json;charset=UTF-8",
                "Transfer-Encoding": "chunked",
                "Date": "Sat, 26 May 2018 01:51:30 GMT",
                "status": "200"
            }
        },
        "timeTaken": "6691"
    }
}]

这样就可以清晰的看到传播细节了。

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;