SpringBoot合集

SpringBoot

本文整理了SpingBoot整合主流开发模块的知识,参考来自松哥系列博客,供学习

SpringBoot基础配置

1. Maven

我们刚开始学习 JavaWeb 的时候,使用 Servlet/JSP 做开发,一个接口搞一个 Servlet ,很头大,后来我们通过隐藏域或者反射等方式,可以减少 Servlet 的创建,但是依然不方便,再后来,我们引入 Struts2/SpringMVC 这一类的框架,来简化我们的开发 ,和 Servlet/JSP 相比,引入框架之后,生产力确实提高了不少,但是用久了,又发现了新的问题,即配置繁琐易出错,要做一个新项目,先搭建环境,环境搭建来搭建去,就是那几行配置,不同的项目,可能就是包不同,其他大部分的配置都是一样的,Java 总是被人诟病配置繁琐代码量巨大,这就是其中一个表现。那么怎么办?Spring Boot 应运而生,Spring Boot 主要提供了如下功能:

  • 为所有基于 Spring 的 Java 开发提供方便快捷的入门体验。
  • 开箱即用,有自己自定义的配置就是用自己的,没有就使用官方提供的默认的。
  • 提供了一系列通用的非功能性的功能,例如嵌入式服务器、安全管理、健康检测等。
  • 绝对没有代码生成,也不需要XML配置。

在创建SpringBoot项目时,如果官方的https://start.spring.io无法访问,可以访问https://start.aliyun.io

创建成功后,pom.xml 坐标

<parent>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-parent</artifactId>
	<version>2.1.4.RELEASE</version>
	<relativePath/> <!-- lookup parent from repository -->
</parent>

1.1 基本功能

当我们创建一个 Spring Boot 工程时,可以继承自一个 spring-boot-starter-parent ,也可以不继承自它,我们先来看第一种情况。先来看 parent 的基本功能有哪些?

  • 定义了 Java 编译版本为 1.8 。
  • 使用 UTF-8 格式编码。
  • 继承自 spring-boot-dependencies,这个里边定义了依赖的版本,也正是因为继承了这个依赖,所以我们在写依赖时才不需要写版本号。
  • 执行打包操作的配置。
  • 自动化的资源过滤。
  • 自动化的插件配置。
  • 针对 application.properties 和 application.yml 的资源过滤,包括通过 profile 定义的不同环境的配置文件,例如 application-dev.properties 和 application-dev.yml

请注意,由于application.propertiesapplication.yml文件接受Spring样式占位符 $ {...} ,因此 Maven 过滤更改为使用 @ .. @ 占位符,当然开发者可以通过设置名为 resource.delimiter 的Maven 属性来覆盖 @ .. @ 占位符

1.2 源码分析

当我们创建一个 Spring Boot 项目后,我们可以在本地 Maven 仓库中看到这个具体的 parent 文件,以 2.1.4 这个版本为例,这里的路径是 C:\Users\sang\.m2\repository\org\springframework\boot\spring-boot-starter-parent\2.1.4.RELEASE\spring-boot-starter-parent-2.1.4.RELEASE.pom ,打开这个文件,快速阅读文件源码,基本上就可以证实我们前面说的功能,如下图:

img

我们可以看到,它继承自 spring-boot-dependencies ,这里保存了基本的依赖信息,另外我们也可以看到项目的编码格式,JDK 的版本等信息,当然也有我们前面提到的数据过滤信息。最后,我们再根据它的 parent 中指定的 spring-boot-dependencies 位置,来看看 spring-boot-dependencies 中的定义:

img

在这里,我们看到了版本的定义以及dependencyManagement节点,明白了为啥 Spring Boot 项目中部分依赖不需要写版本号了。

1.3 不用 parent

但是并非所有的公司都需要这个 parent ,有的时候,公司里边会有自己定义的 parent ,我们的 Spring Boot 项目要继承自公司内部的 parent ,这个时候该怎么办呢?

一个简单的办法就是我们自行定义dependencyManagement节点,然后在里边定义好版本号,再接下来在引用依赖时也就不用写版本号了,像下面这样:

<dependencyManagement>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-dependencies</artifactId>
			<version>2.1.4.RELEASE</version>
			<type>pom</type>
			<scope>import</scope>
		</dependency>
	</dependencies>
</dependencyManagement>

这样写之后,依赖的版本号问题虽然解决了,但是关于打包的插件、编译的 JDK 版本、文件的编码格式等等这些配置,在没有 parent 的时候,这些统统要自己去配置

1.4 <resources/>

pom.xml

<properties>
    <javaboy.name>www.lucifer.org</javaboy.name>
</properties>

<build>
    <resources>
        <resource>
            <directory>src/main/resources</directory>
            <filtering>true</filtering>
          	<!-- 要处理a的,但只写这个会忽略其他的-->
            <includes>
                <include>a.properties</include>
            </includes>
        </resource>
        <resource>
          	<!-- 所以加上此语句,表示除了a,其他的不要处理 -->
            <directory>src/main/resources</directory>
            <filtering>false</filtering>
            <excludes>
                <exclude>a.properties</exclude>
            </excludes>
        </resource>
    </resources>
</build>

a.properties文件的定义

a.name=${javaboy.name}

在打包的时候,a.name会直接从<properties>拿取值,得到www.lucifer.org

2. @SpringBootApplication

2.1 @SpringBootConfiguration

启动类上的注解 @SpringBootApplication引用了@SpringBootConfiguration

image-20210521102815674

这里面又直接调用了@Configuration,这就引出了另一个问题,在普通的配置类MyConfig中,@Configuration@Component看似功能相同,为什么要用@Configuration,通过以下配置

@Configuration
public class MyConfig {
    @Bean
    Author author() {
        return new Author();
    }

    @Bean
    Book book() {
        return new Book(author());
    }
}

发现使用@Configuration创建出来了的Author对象通过代理对象了的,而@Component没有,其中Book(author())如果Book里的Author之前已经创建,就会直接使用,并不会重复创建,这里就不会

2.2 EnableAutoConfiguration

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import({AutoConfigurationImportSelector.class})
public @interface EnableAutoConfiguration {
    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

    Class<?>[] exclude() default {};

    String[] excludeName() default {};
}

在原生的Spring框架中,组件装配有三个阶段:

  • Spring2.5,@Component
  • Spring3.0+,使用@Configuration+@Bean
  • Spring3.1+,@EnableXXX+@Import

为了说明@EnableXXX+@Import,先定义实体类

public class Apple {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

public class Banana {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

有四种定义注解的方式

public class FruitImportSelector implements ImportSelector {
    @Override
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
        return new String[]{Apple.class.getName(), Banana.class.getName()};
    }
}

public class FruitImportDefinitionRegistar implements ImportBeanDefinitionRegistrar {
    @Override
    public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
        registry.registerBeanDefinition("apple", new RootBeanDefinition(Apple.class));
        registry.registerBeanDefinition("banana", new RootBeanDefinition(Banana.class));
    }
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
//1.@Import({Apple.class, Banana.class})
//2.可以直接导入配置类
//3.@Import({FruitImportSelector.class})
@Import({FruitImportDefinitionRegistar.class})
public @interface EnableFruit {
}

从而可以发现

image-20210521105018844

image-20210521105101247

image-20210521105547266

3. 基础配置

3.1 Tomcat配置

基本配置

更改pom.xml文件排除自带的Tomcat后就能自定义自己所需的

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
   <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>

可以任何容器,在配置文件中

#不要开启web容器,以普通SE项目运行
spring.main.web-application-type=none
#更改端口
server.port=8888
#关闭所有的 http 端点
server.port=-1
#随机端口
server.port=0
#端口压缩
server.compression.enabled=true

随机端口需要通过监听器获取

@Component
public class MyApplicationListener implements ApplicationListener<WebServerInitializedEvent> {
    @Override
    public void onApplicationEvent(WebServerInitializedEvent event) {
        System.out.println("event.getWebServer().getPort() = " + event.getWebServer().getPort());
    }
}

日志配置

# 生成的访问日志将在该目录下
server.tomcat.basedir=my-tomcat
# 开启访问日志,默认的日志位置在项目运行的临时目录中,默认生成的日志格式 access_log.2021-05-12.log
server.tomcat.accesslog.enabled=true
# 生成日志文件名的前缀,默认是 access_log
server.tomcat.accesslog.prefix=tomcat_log
# 生成的日志文件后缀
server.tomcat.accesslog.suffix=.log
# 日志文件名中的日期格式
server.tomcat.accesslog.file-date-format=.yyyyMMdd

# 生成的日志文件内容格式也是可以调整的
# %h 请求的客户端 IP
# %l 用户的身份
# %u 用户名
# %t 请求时间
# %r 请求地址
# %s 响应的状态码
# %b 响应的大小
server.tomcat.accesslog.pattern=%h %l %u %t \"%r\" %s %b

# 服务器内部日志开启

logging.level.org.apache.tomcat=debug
logging.level.org.apache.catalina=debug

HTTPS证书

首先我们需要有一个 https 证书,我们可以从各个云服务厂商处申请一个免费的,不过自己做实验没有必要这么麻烦,我们可以直接借助 Java 自带的 JDK 管理工具 keytool 来生成一个免费的 https 证书。

进入到 %JAVVA_HOME%\bin 目录下,执行如下命令生成一个数字证书:

keytool -genkey -alias tomcathttps -keyalg RSA -keysize 2048  -keystore lucifer.p12 -validity 365

命令含义如下:

  • genkey 表示要创建一个新的密钥。
  • alias 表示 keystore 的别名。
  • keyalg 表示使用的加密算法是 RSA ,一种非对称加密算法。
  • keysize 表示密钥的长度。
  • keystore 表示生成的密钥存放位置。
  • validity 表示密钥的有效时间,单位为天。

image-20210521155752252

配置application.properties

server.ssl.key-alias=myhttps
server.ssl.key-store=classpath:lucifer.p12
server.ssl.key-store-password=123456

配置https跳转

考虑到 Spring Boot 不支持同时启动 HTTP 和 HTTPS ,为了解决这个问题,我们这里可以配置一个请求转发,当用户发起 HTTP 调用时,自动转发到 HTTPS 上

@Configuration
public class TomcatConfig {
    @Bean
    TomcatServletWebServerFactory tomcatServletWebServerFactory() {
        TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(){
            @Override
            protected void postProcessContext(Context context) {
                SecurityConstraint securityConstraint = new SecurityConstraint();
                securityConstraint.setUserConstraint("CONFIDENTIAL");
                SecurityCollection collection = new SecurityCollection();
                collection.addPattern("/*");
                securityConstraint.addCollection(collection);
                context.addConstraint(securityConstraint);
            }
        };
        factory.addAdditionalTomcatConnectors(myConnectors());
        return factory;
    }

    private Connector myConnectors() {
        Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
        connector.setScheme("http");
        //http和默认的8080不能占用同一个端口
        connector.setPort(8081);
        connector.setSecure(false);
        connector.setRedirectPort(8080);
        return connector;
    }
}

3.2 application.properties

首先,当我们创建一个 Spring Boot 工程时,默认 resources 目录下就有一个 application.properties 文件,可以在 application.properties 文件中进行项目配置,但是这个文件并非唯一的配置文件,在 Spring Boot 中,一共有 4 个地方可以存放 application.properties 文件

  • 当前项目根目录下的 config 目录下
  • 当前项目的根目录下
  • resources 目录下的 config 目录下
  • resources 目录下

按如上顺序,四个配置文件的优先级依次降低。如下:

img

这四个位置是默认位置,即 Spring Boot 启动,默认会从这四个位置按顺序去查找相关属性并加载。但是,这也不是绝对的,我们也可以在项目启动时自定义配置文件位置。

例如,现在在 resources 目录下创建一个 javaboy 目录,目录中存放一个 application.properties 文件,那么正常情况下,当我们启动 Spring Boot 项目时,这个配置文件是不会被自动加载的。我们可以通过 spring.config.location 属性来手动的指定配置文件位置,指定完成后,系统就会自动去指定目录下查找 application.properties 文件。

img

此时启动项目,就会发现,项目以 classpath:/javaboy/application.propertie 配置文件启动。

这是在开发工具中配置了启动位置,如果项目已经打包成 jar ,在启动命令中加入位置参数即可:

java -jar properties-0.0.1-SNAPSHOT.jar --spring.config.location=classpath:/javaboy/

文件名问题

对于 application.properties 而言,它不一定非要叫 application ,但是项目默认是去加载名为 application 的配置文件,如果我们的配置文件不叫 application ,也是可以的,但是,需要明确指定配置文件的文件名。

方式和指定路径一致,只不过此时的 key 是 spring.config.name 。

首先我们在 resources 目录下创建一个 app.properties 文件,然后在 IDEA 中指定配置文件的文件名:

img

指定完配置文件名之后,再次启动项目,此时系统会自动去默认的四个位置下面分别查找名为 app.properties 的配置文件。当然,允许自定义文件名的配置文件不放在四个默认位置,而是放在自定义目录下,此时就需要明确指定 spring.config.location

配置文件位置和文件名称可以同时自定义。

普通的属性注入

由于 Spring Boot 源自 Spring ,所以 Spring 中存在的属性注入,在 Spring Boot 中一样也存在。由于 Spring Boot 中,默认会自动加载 application.properties 文件,所以简单的属性注入可以直接在这个配置文件中写。

例如,现在定义一个 Book 类:

public class Book {
    private Long id;
    private String name;
    private String author;
    //省略 getter/setter
}

然后,在 application.properties 文件中定义属性:

book.name=三国演义
book.author=罗贯中
book.id=1

按照传统的方式(Spring中的方式),可以直接通过 @Value 注解将这些属性注入到 Book 对象中:

@Component
public class Book {
    @Value("${book.id}")
    private Long id;
    @Value("${book.name}")
    private String name;
    @Value("${book.author}")
    private String author;
    //省略getter/setter
}

注意

Book 对象本身也要交给 Spring 容器去管理,如果 Book 没有交给 Spring 容器,那么 Book 中的属性也无法从 Spring 容器中获取到值。

配置完成后,在 Controller 或者单元测试中注入 Book 对象,启动项目,就可以看到属性已经注入到对象中了。

一般来说,我们在 application.properties 文件中主要存放系统配置,这种自定义配置不建议放在该文件中,可以自定义 properties 文件来存在自定义配置。

例如在 resources 目录下,自定义 book.properties 文件,内容如下:

book.name=三国演义
book.author=罗贯中
book.id=1

此时,项目启动并不会自动的加载该配置文件,如果是在 XML 配置中,可以通过如下方式引用该 properties 文件:

<context:property-placeholder location="classpath:book.properties"/>

如果是在 Java 配置中,可以通过@PropertySource来引入配置:

@Component
@PropertySource("classpath:book.properties")
public class Book {
    @Value("${book.id}")
    private Long id;
    @Value("${book.name}")
    private String name;
    @Value("${book.author}")
    private String author;
    //getter/setter
}

这样,当项目启动时,就会自动加载 book.properties 文件。

这只是 Spring 中属性注入的一个简单用法,和 Spring Boot 没有任何关系。

类型安全的属性注入

Spring Boot 引入了类型安全的属性注入,如果采用 Spring 中的配置方式,当配置的属性非常多的时候,工作量就很大了,而且容易出错。

使用类型安全的属性注入,可以有效的解决这个问题。

@Component
@PropertySource("classpath:book.properties")
@ConfigurationProperties(prefix = "book")
public class Book {
    private Long id;
    private String name;
    private String author;
    //省略getter/setter
}

这里,主要是引入@ConfigurationProperties(prefix = “book”)注解,并且配置了属性的前缀,此时会自动将 Spring 容器中对应的数据注入到对象对应的属性中,就不用通过 @Value 注解挨个注入了,减少工作量并且避免出错

环境配置

在实际开发配置中,要分环境开发

image-20210521163450670

配置 application.properties 选择特定的开发端口

spring.profiles.active=prod
# application-prod.properties
server.port=8088
# application-dev.properties
server.port=8080
# application-test.properties
server.port=8888

3.3 Yaml配置

首先application.yaml在Spring Boot中可以写在四个不同的位置,分别是如下位置:

  • 项目根目录下的config目录中
  • 项目根目录下
  • classpath下的config目录中
  • classpath目录下

四个位置中的application.yaml文件的优先级按照上面列出的顺序依次降低。即如果有同一个属性在四个文件中都出现了,以优先级高的为准。

那么application.yaml是不是必须叫application.yaml这个名字呢?当然不是必须的。开发者可以自己定义yaml名字,自己定义的话,需要在项目启动时指定配置文件的名字,像下面这样:

img

当然这是在IntelliJ IDEA中直接配置的,如果项目已经打成jar包了,则在项目启动时加入如下参数:

java -jar myproject.jar --spring.config.name=app

这样配置之后,在项目启动时,就会按照上面所说的四个位置按顺序去查找一个名为app.yaml的文件。当然这四个位置也不是一成不变的,也可以自己定义,有两种方式,一个是使用spring.config.location属性,另一个则是使用spring.config.additional-location这个属性,在第一个属性中,表示自己重新定义配置文件的位置,项目启动时就按照定义的位置去查找配置文件,这种定义方式会覆盖掉默认的四个位置,也可以使用第二种方式,第二种方式则表示在四个位置的基础上,再添加几个位置,新添加的位置的优先级大于原本的位置。

配置方式如下:

img

这里要注意,配置文件位置时,值一定要以/结尾。

数组注入

yaml也支持数组注入,例如

my:
  servers:
	- dev.example.com
	- another.example.com

这段数据可以绑定到一个带Bean的数组中:

@ConfigurationProperties(prefix="my")
@Component
public class Config {

	private List<String> servers = new ArrayList<String>();

	public List<String> getServers() {
		return this.servers;
	}
}

项目启动后,配置中的数组会自动存储到servers集合中。当然,yaml不仅可以存储这种简单数据,也可以在集合中存储对象。例如下面这种:

redis:
  redisConfigs:
    - host: 192.168.66.128
      port: 6379
    - host: 192.168.66.129
      port: 6380

这个可以被注入到如下类中:

@Component
@ConfigurationProperties(prefix = "redis")
public class RedisCluster {
    private List<SingleRedisConfig> redisConfigs;
	//省略getter/setter
}

优缺点

不同于properties文件的无序,yaml配置是有序的,这一点在有些配置中是非常有用的,例如在Spring Cloud Zuul的配置中,当我们配置代理规则时,顺序就显得尤为重要了。当然yaml配置也不是万能的,例如,yaml配置目前不支持@PropertySource注解

4. 日志详解

1. Java 日志概览

说到 Java 日志,很多初学者可能都比较懵,因为这里涉及到太多东西了:Apache Commons LoggingSlf4jLog4jLog4j2LogbackJava Util Logging 等等,这些框架各自有什么作用?他们之间有什么区别?

1.1 总体概览

下面这张图很好的展示了 Java 中的日志体系:

img

可以看到,Java 中的日志框架主要分为两大类:日志门面日志实现

日志门面

日志门面定义了一组日志的接口规范,它并不提供底层具体的实现逻辑。Apache Commons LoggingSlf4j 就属于这一类。

日志实现

日志实现则是日志具体的实现,包括日志级别控制、日志打印格式、日志输出形式(输出到数据库、输出到文件、输出到控制台等)。Log4jLog4j2Logback 以及 Java Util Logging 则属于这一类。

将日志门面和日志实现分离其实是一种典型的门面模式,这种方式可以让具体业务在不同的日志实现框架之间自由切换,而不需要改动任何代码,开发者只需要掌握日志门面的 API 即可。

日志门面是不能单独使用的,它必须和一种具体的日志实现框架相结合使用。

那么日志框架是否可以单独使用呢?

技术上来说当然没问题,但是我们一般不会这样做,因为这样做可维护性很差,而且后期扩展不易。例如 A 开发了一个工具包使用 Log4j 打印日志,B 引用了这个工具包,但是 B 喜欢使用 Logback 打印日志,此时就会出现一个业务使用两个甚至多个日志框架,开发者也需要维护多个日志的配置文件。因此我们都是用日志门面打印日志。

1.2 日志级别

使用日志级别的好处在于,调整级别,就可以屏蔽掉很多调试相关的日志输出。不同的日志实现定义的日志级别不太一样,不过也都大同小异。

Java Util Logging

Java Util Logging 定义了 7 个日志级别,从严重到普通依次是:

  • SEVERE
  • WARNING
  • INFO
  • CONFIG
  • FINE
  • FINER
  • FINEST

因为默认级别是 INFO,因此 INFO 级别以下的日志,不会被打印出来。

Log4j

Log4j 定义了 8 个日志级别(除去 OFF 和 ALL,可以说分为 6 个级别),从严重到普通依次是:

  • OFF:最高等级的,用于关闭所有日志记录。
  • FATAL:重大错误,这种级别可以直接停止程序了。
  • ERROR:打印错误和异常信息,如果不想输出太多的日志,可以使用这个级别。
  • WARN:警告提示。
  • INFO:用于生产环境中输出程序运行的一些重要信息,不能滥用。
  • DEBUG:用于开发过程中打印一些运行信息。
  • TRACE
  • ALL 最低等级的,用于打开所有日志记录。

Logback

Logback 日志级别比较简单,从严重到普通依次是:

  • ERROR
  • WARN
  • INFO
  • DEBUG
  • TRACE

1.3 综合对比

Java Util Logging 系统在 JVM 启动时读取配置文件并完成初始化,一旦应用程序开始运行,就无法修改配置。另外,这种日志实现配置也不太方便,只能在 JVM 启动时传递参数,像下面这样:

-Djava.util.logging.config.file=<config-file-name>

由于这些局限性,导致 Java Util Logging 并未广泛使用。

Log4j 虽然配置繁琐,但是一旦配置完成,使用起来就非常方便,只需要将相关的配置文件放到 classpath 下即可。在很多情况下,Log4j 的配置文件我们可以在不同的项目中反复使用。

Log4j 可以和 Apache Commons Logging 搭配使用,Apache Commons Logging 会自动搜索并使用 Log4j,如果没有找到 Log4j,再使用 Java Util Logging

Log4j + Apache Commons Logging 组合更得人心的是 Slf4j + Logback 组合。

LogbackSlf4j 的原生实现框架,它也出自 Log4j 作者(Ceki Gülcü)之手,但是相比 Log4j,它拥有更多的优点、特性以及更强的性能。

1.4 最佳实践

  • 如果不想添加任何依赖,使用 Java Util Logging 或框架容器已经提供的日志接口。
  • 如果比较在意性能,推荐:Slf4j + Logback
  • 如果项目中已经使用了 Log4j 且没有发现性能问题,推荐组合为:Slf4j + Log4j2

2. Spring Boot 日志实现

Spring Boot 使用 Apache Commons Logging 作为内部的日志框架门面,它只是一个日志接口,在实际应用中需要为该接口来指定相应的日志实现。

Spring Boot 默认的日志实现是 Logback。这个很好查看:随便启动一个 Spring Boot 项目,从控制台找一行日志,例如下面这样:

img

考虑到最后的 prod 是一个可以变化的字符,我们在项目中全局搜索:The following profiles are active,结果如下:

img

在日志输出的那一行 debug。然后再次启动项目,如下图:

img

此时我们就可以看到真正的日志实现是 Logback

其他的诸如 Java Util LoggingLog4j 等框架,Spring Boot 也有很好的支持。

在 Spring Boot 项目中,只要添加了如下 web 依赖,日志依赖就自动添加进来了:

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

2.1 Spring Boot 日志配置

Spring Boot 的日志系统会自动根据 classpath 下的内容选择合适的日志配置,在这个过程中首选 Logback。

如果开发者需要修改日志级别,只需要在 application.properties 文件中通过 logging.level 前缀+包名 的形式进行配置即可,例如下面这样:

logging.level.org.springframework.web=debug
logging.level.org.hibernate=error

如果你想将日志输出到文件,可以通过如下配置指定日志文件名:

logging.file.name=javaboy.log

logging.file.name 可以只指定日志文件名,也可以指定日志文件全路径,例如下面这样:

logging.file.name=/Users/sang/Documents/javaboy/javaboy.log

如果你只是想重新定义输出日志文件的路径,也可以使用 logging.file.path 属性,如下:

logging.file.path=/Users/sang/Documents/javaboy

如果想对输出到文件中的日志进行精细化管理,还有如下一些属性可以配置:

  • logging.logback.rollingpolicy.file-name-pattern:日志归档的文件名,日志文件达到一定大小之后,自动进行压缩归档。
  • logging.logback.rollingpolicy.clean-history-on-start:是否在应用启动时进行归档管理。
  • logging.logback.rollingpolicy.max-file-size:日志文件大小上限,达到该上限后,会自动压缩。
  • logging.logback.rollingpolicy.total-size-cap:日志文件被删除之前,可以容纳的最大大小。
  • logging.logback.rollingpolicy.max-history:日志文件保存的天数。

日志文件归档这块,小伙伴们感兴趣可以自己试下,可以首先将 max-file-size 属性调小,这样方便看到效果:

logging.logback.rollingpolicy.max-file-size=1MB

然后添加如下接口:

@RestController
public class HelloController {
    private static final Logger logger = getLogger(HelloController.class);
    @GetMapping("/hello")
    public void hello() {
        for (int i = 0; i < 100000; i++) {
            logger.info("hello javaboy");
        }
    }
}

访问该接口,可以看到最终生成的日志文件被自动压缩了:

img

application.properties 中还可以配置日志分组。

日志分组能够把相关的 logger 放到一个组统一管理。

例如我们可以定义一个 tomcat 组:

logging.group.tomcat=org.apache.catalina,org.apache.coyote, org.apache.tomcat

然后统一管理 tomcat 组中的所有 logger:

logging.level.tomcat=TRACE

Spring Boot 中还预定义了两个日志分组 web 和 sql,如下:

img

不过在 application.properties 中只能实现对日志一些非常简单的配置,如果想实现更加细粒度的日志配置,那就需要使用日志实现的原生配置,例如 Logbackclasspath:logback.xmlLog4jclasspath:log4j.xml 等。如果这些日志配置文件存在于 classpath 下,那么默认情况下,Spring Boot 就会自动加载这些配置文件。

2.2 Logback 配置

2.2.1 基本配置

默认的 Logback 配置文件名有两种:

  • logback.xml:这种配置文件会直接被日志框架加载。
  • logback-spring.xml:这种配置文件不会被日志框架直接加载,而是由 Spring Boot 去解析日志配置,可以使用 Spring Boot 的高级 Profile 功能。

Spring Boot 中为 Logback 提供了四个默认的配置文件,位置在 org/springframework/boot/logging/logback/,分别是:

  • defaults.xml:提供了公共的日志配置,日志输出规则等。
  • console-appender.xml:使用 CONSOLE_LOG_PATTERN 添加一个ConsoleAppender。
  • file-appender.xml:添加一个 RollingFileAppender。
  • base.xml:为了兼容旧版 Spring Boot 而提供的。

如果需要自定义 logback.xml 文件,可以在自定义时使用这些默认的配置文件,也可以不使用。一个典型的 logback.xml 文件如下(resources/logback.xml):

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml" />
    <root level="INFO">
        <appender-ref ref="CONSOLE" />
    </root>
    <logger name="org.springframework.web" level="DEBUG"/>
</configuration>

可以通过 include 引入 Spring Boot 已经提供的配置文件,也可以自定义。

2.2.2 输出到文件

如果想禁止控制台的日志输出,转而将日志内容输出到一个文件,我们可以自定义一个 logback-spring.xml 文件,并引入前面所说的 file-appender.xml 文件。

像下面这样(resources/logback-spring.xml):

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml" />
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}/}spring.log}"/>
    <include resource="org/springframework/boot/logging/logback/file-appender.xml" />
    <root level="INFO">
        <appender-ref ref="FILE" />
    </root>
</configuration>

2.3 Log4j 配置

如果 classpath 下存在 Log4j2 的依赖,Spring Boot 会自动进行配置。

默认情况下 classpath 下当然不存在 Log4j2 的依赖,如果想使用 Log4j2,可以排除已有的 Logback,然后再引入 Log4j2,如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

Log4j2 的配置就比较容易了,在 reources 目录下新建 log4j2.xml 文件,内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<configuration status="warn">
    <properties>
        <Property name="app_name">logging</Property>
        <Property name="log_path">logs/${app_name}</Property>
    </properties>
    <appenders>
        <console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="[%d][%t][%p][%l] %m%n" />
        </console>
        <RollingFile name="RollingFileInfo" fileName="${log_path}/info.log"
                     filePattern="${log_path}/$${date:yyyy-MM}/info-%d{yyyy-MM-dd}-%i.log.gz">
            <Filters>
                <ThresholdFilter level="INFO" />
                <ThresholdFilter level="WARN" onMatch="DENY"
                                 onMismatch="NEUTRAL" />
            </Filters>
            <PatternLayout pattern="[%d][%t][%p][%c:%L] %m%n" />
            <Policies>
                <TimeBasedTriggeringPolicy interval="1" modulate="true" />
                <SizeBasedTriggeringPolicy size="2 MB" />
            </Policies>
            <DefaultRolloverStrategy compressionLevel="0" max="10"/>
        </RollingFile>
        <RollingFile name="RollingFileWarn" fileName="${log_path}/warn.log"
                     filePattern="${log_path}/$${date:yyyy-MM}/warn-%d{yyyy-MM-dd}-%i.log.gz">
            <Filters>
                <ThresholdFilter level="WARN" />
                <ThresholdFilter level="ERROR" onMatch="DENY"
                                 onMismatch="NEUTRAL" />
            </Filters>
            <PatternLayout pattern="[%d][%t][%p][%c:%L] %m%n" />
            <Policies>
                <TimeBasedTriggeringPolicy interval="1" modulate="true" />
                <SizeBasedTriggeringPolicy size="2 MB" />
            </Policies>
            <DefaultRolloverStrategy compressionLevel="0" max="10"/>
        </RollingFile>

        <RollingFile name="RollingFileError" fileName="${log_path}/error.log"
                     filePattern="${log_path}/$${date:yyyy-MM}/error-%d{yyyy-MM-dd}-%i.log.gz">
            <ThresholdFilter level="ERROR" />
            <PatternLayout pattern="[%d][%t][%p][%c:%L] %m%n" />
            <Policies>
                <TimeBasedTriggeringPolicy interval="1" modulate="true" />
                <SizeBasedTriggeringPolicy size="2 MB" />
            </Policies>
            <DefaultRolloverStrategy compressionLevel="0" max="10"/>
        </RollingFile>
    </appenders>
    <loggers>
        <root level="info">
            <appender-ref ref="Console" />
            <appender-ref ref="RollingFileInfo" />
            <appender-ref ref="RollingFileWarn" />
            <appender-ref ref="RollingFileError" />
        </root>
    </loggers>
</configuration>

首先在 properties 节点中指定了应用名称以及日志文件位置。

然后通过几个不同的 RollingFile 对不同级别的日志分别处理,不同级别的日志将输出到不同的文件,并按照各自的命名方式进行压缩。

这段配置比较程式化,小伙伴们可以保存下来做成 IntelliJ IDEA 模版以便日常使用。

SpringBoot整合视图层

1. Thymeleaf

Thymeleaf 是新一代 Java 模板引擎,它类似于 Velocity、FreeMarker 等传统 Java 模板引擎,但是与传统 Java 模板引擎不同的是,Thymeleaf 支持 HTML 原型。

它既可以让前端工程师在浏览器中直接打开查看样式,也可以让后端工程师结合真实数据查看显示效果,同时,SpringBoot 提供了 Thymeleaf 自动化配置解决方案,因此在 SpringBoot 中使用 Thymeleaf 非常方便。

事实上, Thymeleaf 除了展示基本的 HTML ,进行页面渲染之外,也可以作为一个 HTML 片段进行渲染,例如我们在做邮件发送时,可以使用 Thymeleaf 作为邮件发送模板。

另外,由于 Thymeleaf 模板后缀为 .html,可以直接被浏览器打开,因此,预览时非常方便。

1.1 整合

  • 创建项目

Spring Boot 中整合 Thymeleaf 非常容易,只需要创建项目时添加 Thymeleaf 即可:

img

创建完成后,pom.xml 依赖如下:

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

当然,Thymeleaf 不仅仅能在 Spring Boot 中使用,也可以使用在其他地方,只不过 Spring Boot 针对 Thymeleaf 提供了一整套的自动化配置方案,这一套配置类的属性在 org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties 中,部分源码如下:

@ConfigurationProperties(prefix = "spring.thymeleaf")
public class ThymeleafProperties {
        private static final Charset DEFAULT_ENCODING = StandardCharsets.UTF_8;
        public static final String DEFAULT_PREFIX = "classpath:/templates/";
        public static final String DEFAULT_SUFFIX = ".html";
        private boolean checkTemplate = true;
        private boolean checkTemplateLocation = true;
        private String prefix = DEFAULT_PREFIX;
        private String suffix = DEFAULT_SUFFIX;
        private String mode = "HTML";
        private Charset encoding = DEFAULT_ENCODING;
        private boolean cache = true;
        //...
}
  • 首先通过 @ConfigurationProperties 注解,将 application.properties 前缀为 spring.thymeleaf 的配置和这个类中的属性绑定。
  • 前三个 static 变量定义了默认的编码格式、视图解析器的前缀、后缀等。
  • 从前三行配置中,可以看出来,Thymeleaf 模板的默认位置在 resources/templates 目录下,默认的后缀是 html
  • 这些配置,如果开发者不自己提供,则使用 默认的,如果自己提供,则在 application.properties 中以 spring.thymeleaf 开始相关的配置。

而我们刚刚提到的,Spring Boot 为 Thymeleaf 提供的自动化配置类,则是 org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration ,部分源码如下:

@Configuration
@EnableConfigurationProperties(ThymeleafProperties.class)
@ConditionalOnClass({ TemplateMode.class, SpringTemplateEngine.class })
@AutoConfigureAfter({ WebMvcAutoConfiguration.class, WebFluxAutoConfiguration.class })
public class ThymeleafAutoConfiguration {
}

可以看到,在这个自动化配置类中,首先导入 ThymeleafProperties ,然后 @ConditionalOnClass 注解表示当当前系统中存在 TemplateModeSpringTemplateEngine 类时,当前的自动化配置类才会生效,即只要项目中引入了 Thymeleaf 相关的依赖,这个配置就会生效。

这些默认的配置我们几乎不需要做任何更改就可以直接使用了。如果开发者有特殊需求,则可以在 application.properties 中配置以 spring.thymeleaf 开头的属性即可。

  • 创建 Controller

接下来我们就可以创建 Controller 了,实际上引入 Thymeleaf 依赖之后,我们可以不做任何配置。新建的 IndexController 如下:

@Controller
public class IndexController {
    @GetMapping("/index")
    public String index(Model model) {
        List<User> users = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            User u = new User();
            u.setId((long) i);
            u.setName("javaboy:" + i);
            u.setAddress("深圳:" + i);
            users.add(u);
        }
        model.addAttribute("users", users);
        return "index";
    }
}
public class User {
    private Long id;
    private String name;
    private String address;
    //省略 getter/setter
}

IndexController 中返回逻辑视图名+数据,逻辑视图名为 index ,意思我们需要在 resources/templates 目录下提供一个名为 index.htmlThymeleaf 模板文件。

  • 创建 Thymeleaf
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<table border="1">
    <tr>
        <td>编号</td>
        <td>用户名</td>
        <td>地址</td>
    </tr>
    <tr th:each="user : ${users}">
        <td th:text="${user.id}"></td>
        <td th:text="${user.name}"></td>
        <td th:text="${user.address}"></td>
    </tr>
</table>
</body>
</html>

Thymeleaf 中,通过 th:each 指令来遍历一个集合,数据的展示通过 th:text 指令来实现,

注意 index.html 最上面要引入 thymeleaf 名称空间。

配置完成后,就可以启动项目了,访问 /index 接口,就能看到集合中的数据了:

img

另外,Thymeleaf 支持在 js 中直接获取 Model 中的变量。例如,在 IndexController 中有一个变量 username

@Controller
public class IndexController {
    @GetMapping("/index")
    public String index(Model model) {
        model.addAttribute("username", "李四");
        return "index";
    }
}

在页面模板中,可以直接在 js 中获取到这个变量:

<script th:inline="javascript">
    var username = [[${username}]];
    console.log(username)
</script>

这个功能算是 Thymeleaf 的特色之一吧。

1.2 手动渲染

前面我们说的是返回一个 Thymeleaf 模板,我们也可以手动渲染 Thymeleaf 模板,这个一般在邮件发送时候有用,例如我在 resources/templates 目录下新建一个邮件模板,如下:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<p>hello 欢迎 <span th:text="${username}"></span>加入 XXX 集团,您的入职信息如下:</p>
<table border="1">
    <tr>
        <td>职位</td>
        <td th:text="${position}"></td>
    </tr>
    <tr>
        <td>薪水</td>
        <td th:text="${salary}"></td>
    </tr>
</table>
<img src="http://www.javaboy.org/images/sb/javaboy.jpg" alt="">
</body>
</html>

这一个 HTML 模板中,有几个变量,我们要将这个 HTML 模板渲染成一个 String 字符串,再把这个字符串通过邮件发送出去,那么如何手动渲染呢?

@Autowired
TemplateEngine templateEngine;
@Test
public void test1() throws MessagingException {
    Context context = new Context();
    context.setVariable("username", "javaboy");
    context.setVariable("position", "Java工程师");
    context.setVariable("salary", 99999);
    String mail = templateEngine.process("mail", context);
    //省略邮件发送
}
  • 渲染时,我们需要首先注入一个 TemplateEngine 对象,这个对象就是在 Thymeleaf 的自动化配置类中配置的(即当我们引入 Thymeleaf 的依赖之后,这个实例就有了)。
  • 然后构造一个 Context 对象用来存放变量。
  • 调用 process 方法进行渲染,该方法的返回值就是渲染后的 HTML 字符串,然后我们将这个字符串发送出去。

这是 Spring Boot 整合 Thymeleaf 的几个关键点,关于 Thymeleaf 这个页面模板本身更多的用法,可以参考 Thymeleaf 的文档:[https://www.thymeleaf.org](

1.3 Thymeleaf 细节

1.3.1 标准表达式语法

简单表达式

${…}

直接使用 th:xx = "${}" 获取对象属性。这个在前面的案例中已经演示过了,不再赘述。

*{…}

可以像 ${...} 一样使用,也可以通过 th:object 获取对象,然后使用 th:xx = "*{}" 获取对象属性,这种简写风格极为清爽,推荐大家在实际项目中使用。

<table border="1" th:object="${user}">
<tr>
    <td>用户名</td>
    <td th:text="*{username}"></td>
</tr>
<tr>
    <td>地址</td>
    <td th:text="*{address}"></td>
</tr>
</table>

#{…}

通常的国际化属性:#{...} 用于获取国际化语言翻译值。

在 resources 目录下新建两个文件:messages.properties 和 messages_zh_CN.properties,内容如下:

messages.properties:

message = javaboy

messages_zh_CN.properties:

message = 江南一点雨

然后在 thymeleaf 中引用 message,系统会根据浏览器的语言环境显示不同的值:

<div th:text="#{message}"></div>

@{…}

  • 引用绝对 URL:
<script type="text/javascript" th:src="@{http://localhost:8080/hello.js}"></script>

等价于:

<script type="text/javascript" src="http://localhost:8080/hello.js"></script>
  • 上下文相关的 URL:

首先在 application.properties 中配置 Spring Boot 的上下文,以便于测试:

server.servlet.context-path=/myapp

引用路径:

<script type="text/javascript" th:src="@{/hello.js}"></script>

等价于:

<script type="text/javascript" src="/myapp/hello.js"></script>
  • 相对 URL:

这个相对是指相对于服务器的 URL,例如如下引用:

<script type="text/javascript" th:src="@{~/hello.js}"></script>

等价于:

<script type="text/javascript" src="/hello.js"></script>

应用程序的上下文 /myapp 将被忽略。

  • 协议相对 URL:
<script type="text/javascript" th:src="@{//localhost:8080/hello.js}"></script>

等价于:

<script type="text/javascript" src="//localhost:8080/hello.js"></script>
  • 带参数的 URL:
<script type="text/javascript" th:src="@{//localhost:8080/hello.js(name='javaboy',age=99)}"></script>

等价于:

<script type="text/javascript" th:src="//localhost:8080/hello.js?name=javaboy&age=99"></script>

~{…}

片段表达式是 Thymeleaf 的特色之一,细粒度可以达到标签级别,这是 JSP 无法做到的。片段表达式拥有三种语法:

  • ~{ viewName }:表示引入完整页面
  • ~{ viewName ::selector}:表示在指定页面寻找片段,其中 selector 可为片段名、jquery选择器等
  • ~{ ::selector}: 表示在当前页寻找

举个简单例子。

在 resources/templates 目录下新建 my_fragment.html 文件,内容如下:

<div th:fragment="javaboy_link"><a href="http://www.javaboy.org">www.javaboy</a></div>
<div th:fragment="itboyhub_link"><a href="http://www.itboyhub.com">www.itboyhub.com</a></div>

这里有两个 div,通过 th:fragment 来定义片段,两个 div 分别具有不同的名字。

然后在另外一个页面中引用该片段:

<table border="1" th:object="${user}" th:fragment="aaa">
<tr>
    <td>用户名</td>
    <td th:text="*{username}"></td>
</tr>
<tr>
    <td>地址</td>
    <td th:text="*{address}"></td>
</tr>
</table>
<hr>
<div th:replace="my_fragment.html"></div>
<hr>
<div th:replace="~{my_fragment.html::javaboy_link}"></div>
<hr>
<div th:replace="~{::aaa}"></div>

通过 th:replace 来引用片段。第一个表示引用完整的 my_fragment.html 页面;第二个表示引用 my_fragment.html 中的名为 javaboy_link 的片段;第三个表示引用当前页面名为 aaa 的片段,也就是上面那个 table。

字面量

这些是一些可以直接写在表达式中的字符,主要有如下几种:

  • 文本字面量: ‘one text’, ‘Another one!’,…
  • 数字字面量: 0, 34, 3.0, 12.3,…
  • 布尔字面量: true, false
  • Null字面量: null
  • 字面量标记:one, sometext, main,…

案例:

<div th:text="'这是 文本字面量(有空格)'"></div>
<div th:text="javaboy"></div>
<div th:text="99"></div>
<div th:text="true"></div>

如果文本是英文,并且不包含空格、逗号等字符,可以不用加单引号。

文本运算

文本可以使用 + 进行拼接。

<div th:text="'hello '+'javaboy'"></div>
<div th:text="'hello '+${user.username}"></div>

如果字符串中包含变量,也可以使用另一种简单的方式,叫做字面量置换,用 | 代替 '...' + '...',如下:

<div th:text="|hello ${user.username}|"></div>
<div th:text="'hello '+${user.username}+' '+|Go ${user.address}|"></div>
算术运算

算术运算有:+, -, *, /%

<div th:with="age=(99*99/99+99-1)">
    <div th:text="${age}"></div>
</div>

th:with 定义了一个局部变量 age,在其所在的 div 中可以使用该局部变量

布尔运算
  • 二元运算符:and, or
  • 布尔非(一元运算符):!, not

案例:

<div th:with="age=(99*99/99+99-1)">
    <div th:text="9 eq 9 or 8 ne 8"></div>
    <div th:text="!(9 eq 9 or 8 ne 8)"></div>
    <div th:text="not(9 eq 9 or 8 ne 8)"></div>
</div>
比较和相等

表达式里的值可以使用 >, <, >=<= 符号比较。==!= 运算符用于检查相等(或者不相等)。注意 XML规定 <> 标签不能用于属性值,所以应当把它们转义为 <>

如果不想转义,也可以使用别名:gt (>);lt (<);ge (>=);le (<=);not (!)还有 eq (==), neq/ne (!=)

举例:

<div th:with="age=(99*99/99+99-1)">
    <div th:text="${age} eq 197"></div>
    <div th:text="${age} ne 197"></div>
    <div th:text="${age} ge 197"></div>
    <div th:text="${age} gt 197"></div>
    <div th:text="${age} le 197"></div>
    <div th:text="${age} lt 197"></div>
</div>
条件运算符

类似于我们 Java 中的三目运算符。

<div th:with="age=(99*99/99+99-1)">
    <div th:text="(${age} ne 197)?'yes':'no'"></div>
</div>

其中,: 后面的部分可以省略,如果省略了,又同时计算结果为 false 时,将返回 null。

内置对象

基本内置对象:

  • #ctx:上下文对象。
  • #vars: 上下文变量。
  • #locale:上下文区域设置。
  • #request:(仅在 Web 上下文中)HttpServletRequest 对象。
  • #response:(仅在 Web 上下文中)HttpServletResponse 对象。
  • #session:(仅在 Web 上下文中)HttpSession 对象。
  • #servletContext:(仅在 Web 上下文中)ServletContext 对象。

在页面可以访问到上面这些内置对象,举个简单例子:

<div th:text='${#session.getAttribute("name")}'></div>

实用内置对象:

  • #execInfo:有关正在处理的模板的信息。
  • #messages:在变量表达式中获取外部化消息的方法,与使用#{…}语法获得的方式相同。
  • #uris:转义URL / URI部分的方法
  • #conversions:执行配置的转换服务(如果有)的方法。
  • #dates:java.util.Date对象的方法:格式化,组件提取等
  • #calendars:类似于#dates但是java.util.Calendar对象。
  • #numbers:用于格式化数字对象的方法。
  • #strings:String对象的方法:contains,startsWith,prepending / appending等
  • #objects:一般对象的方法。
  • #bools:布尔评估的方法。
  • #arrays:数组方法。
  • #lists:列表的方法。
  • #sets:集合的方法。
  • #maps:地图方法。
  • #aggregates:在数组或集合上创建聚合的方法。
  • #ids:处理可能重复的id属性的方法(例如,作为迭代的结果)。

这是一些内置对象以及工具方法,使用方式也都比较容易,如果使用的是 IntelliJ IDEA,都会自动提示对象中的方法,很方便。

举例:

<div th:text="${#execInfo.getProcessedTemplateName()}"></div>
<div th:text="${#arrays.length(#request.getAttribute('names'))}"></div>

1.3.2 设置属性值

这个是给 HTML 元素设置属性值。可以一次设置多个,多个之间用 , 分隔开。

例如:

<img th:attr="src=@{/1.png},title=${user.username},alt=${user.username}">

会被渲染成:

<img src="/myapp/1.png" title="javaboy" alt="javaboy">

当然这种设置方法不太美观,可读性也不好。Thymeleaf 还支持在每一个原生的 HTML 属性前加上 th: 前缀的方式来使用动态值,像下面这样:

<img th:src="@{/1.png}" th:alt="${user.username}" th:title="${user.username}">

这种写法看起来更清晰一些,渲染效果和前面一致。

上面案例中的 alt 和 title 则是两个特殊的属性,可以一次性设置,像下面这样:

<img th:src="@{/1.png}" th:alt-title="${user.username}">

这个等价于前文的设置。

1.3.3 遍历

数组/集合/Map/Enumeration/Iterator 等的遍历也算是一个非常常见的需求,Thymeleaf 中通过 th:each 来实现遍历,像下面这样:

<table border="1">
    <tr th:each="u : ${users}">
        <td th:text="${u.username}"></td>
        <td th:text="${u.address}"></td>
    </tr>
</table>

users 是要遍历的集合/数组,u 则是集合中的单个元素。

遍历的时候,我们可能需要获取遍历的状态,Thymeleaf 也对此提供了支持:

  • index:当前的遍历索引,从0开始。
  • count:当前的遍历索引,从1开始。
  • size:被遍历变量里的元素数量。
  • current:每次遍历的遍历变量。
  • even/odd:当前的遍历是偶数次还是奇数次。
  • first:当前是否为首次遍历。
  • last:当前是否为最后一次遍历。

u 后面的 state 表示遍历状态,通过遍历状态可以引用上面的属性。

<table border="1">
    <tr th:each="u,state : ${users}">
        <td th:text="${u.username}"></td>
        <td th:text="${u.address}"></td>
        <td th:text="${state.index}"></td>
        <td th:text="${state.count}"></td>
        <td th:text="${state.size}"></td>
        <td th:text="${state.current}"></td>
        <td th:text="${state.even}"></td>
        <td th:text="${state.odd}"></td>
        <td th:text="${state.first}"></td>
        <td th:text="${state.last}"></td>
    </tr>
</table>

1.3.4 分支语句

只显示奇数次的遍历,可以使用 th:if,如下:

<table border="1">
    <tr th:each="u,state : ${users}" th:if="${state.odd}">
        <td th:text="${u.username}"></td>
        <td th:text="${u.address}"></td>
        <td th:text="${state.index}"></td>
        <td th:text="${state.count}"></td>
        <td th:text="${state.size}"></td>
        <td th:text="${state.current}"></td>
        <td th:text="${state.even}"></td>
        <td th:text="${state.odd}"></td>
        <td th:text="${state.first}"></td>
        <td th:text="${state.last}"></td>
    </tr>
</table>

th:if 不仅仅只接受布尔值,也接受其他类型的值,例如如下值都会判定为 true:

  • 如果值是布尔值,并且为 true。
  • 如果值是数字,并且不为 0。
  • 如果值是字符,并且不为 0。
  • 如果值是字符串,并且不为 “false”, “off” 或者 “no”。
  • 如果值不是布尔值,数字,字符或者字符串。

但是如果值为 null,th:if 会求值为 false。

th:unless 的判定条件则与 th:if 完全相反。

<table border="1">
    <tr th:each="u,state : ${users}" th:unless="${state.odd}">
        <td th:text="${u.username}"></td>
        <td th:text="${u.address}"></td>
        <td th:text="${state.index}"></td>
        <td th:text="${state.count}"></td>
        <td th:text="${state.size}"></td>
        <td th:text="${state.current}"></td>
        <td th:text="${state.even}"></td>
        <td th:text="${state.odd}"></td>
        <td th:text="${state.first}"></td>
        <td th:text="${state.last}"></td>
    </tr>
</table>

这个显示效果则与上面的完全相反。

当可能性比较多的时候,也可以使用 switch:

<table border="1">
    <tr th:each="u,state : ${users}">
        <td th:text="${u.username}"></td>
        <td th:text="${u.address}"></td>
        <td th:text="${state.index}"></td>
        <td th:text="${state.count}"></td>
        <td th:text="${state.size}"></td>
        <td th:text="${state.current}"></td>
        <td th:text="${state.even}"></td>
        <td th:text="${state.odd}"></td>
        <td th:text="${state.first}"></td>
        <td th:text="${state.last}"></td>
        <td th:switch="${state.odd}">
            <span th:case="true">odd</span>
            <span th:case="*">even</span>
        </td>
    </tr>
</table>

th:case="*" 则表示默认选项。

1.3.5 本地变量

这个我们前面已经涉及到了,使用 th:with 可以定义一个本地变量。

1.3.6 内联

我们可以使用属性将数据放入页面模版中,但是很多时候,内联的方式看起来更加直观一些,像下面这样:

<div>hello [[${user.username}]]</div>

用内联的方式去做拼接也显得更加自然。

[[...]] 对应于 th:text (结果会是转义的 HTML),[(...)]对应于 th:utext,它不会执行任何的 HTML 转义。

像下面这样:

<div th:with="str='hello <strong>javaboy</strong>'">
    <div>[[${str}]]</div>
    <div>[(${str})]</div>
</div>

最终的显示效果如下:

img

不过内联方式有一个问题。我们使用 Thymeleaf 的一大优势在于不用动态渲染就可以直接在浏览器中看到显示效果,当我们使用属性配置的时候确实是这样,但是如果我们使用内联的方式,各种表达式就会直接展示在静态网页中。

也可以在 js 或者 css 中使用内联,以 js 为例,js 中需要通过 th:inline="javascript" 开启内联

<script th:inline="javascript">
    var username=[[${user.username}]]
    console.log(username)
</script>

大家在网上可能都看到过一些代码生成工具,很酷!MyBatis、Service、Controller、Model 全部都可以自动生成。

这些工具的实现其实非常 Easy,只要熟悉 JDBC API 和任意一种页面模版就可以实现。今天松哥就尝试使用 Freemarker 做一个简单的案例,带大家揭开代码生成工具的神秘面纱。

我们先来看看 Spring Boot 整合 Freemarker,然后再来看看如何实现自动生成代码。

2. Freemarker

这是一个相当老牌的开源的免费的模版引擎,基于Apache许可证2.0版本发布。

通过 Freemarker 模版,我们可以将数据渲染成 HTML 网页、电子邮件、配置文件以及源代码等。Freemarker 不是面向最终用户的,而是一个 Java 类库,我们可以将之作为一个普通的组件嵌入到我们的产品中。

来看一张来自 Freemarker 官网的图片:

img

可以看到,Freemarker 可以将模版和数据渲染成 HTML 。

Freemarker 模版后缀为 .ftlh(FreeMarker Template Language)。FTL 是一种简单的、专用的语言,它不是像 Java 那样成熟的编程语言。在模板中,你可以专注于如何展现数据, 而在模板之外可以专注于要展示什么数据。

2.1 整合 Spring Boot

在 SSM 中整合 Freemarker ,所有的配置文件加起来,前前后后大约在 50 行左右,Spring Boot 中要几行配置呢? 0 行!

2.1.1 创建工程

首先创建一个 Spring Boot 工程,引入 Freemarker 依赖,如下图:

img

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

工程创建完成后,在 org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration 类中,可以看到关于 Freemarker 的自动化配置:

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ freemarker.template.Configuration.class, FreeMarkerConfigurationFactory.class })
@EnableConfigurationProperties(FreeMarkerProperties.class)
@Import({ FreeMarkerServletWebConfiguration.class, FreeMarkerReactiveWebConfiguration.class,
		FreeMarkerNonWebConfiguration.class })
public class FreeMarkerAutoConfiguration {
}

从这里可以看出,当 classpath 下存在 freemarker.template.Configuration 以及 FreeMarkerConfigurationFactory 时,配置才会生效,也就是说当我们引入了 Freemarker 之后,配置就会生效。但是这里的自动化配置只做了模板位置检查,其他配置则是在导入的 FreeMarkerServletWebConfiguration 配置中完成的。那么我们再来看看 FreeMarkerServletWebConfiguration 类,部分源码如下:

@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnClass({ Servlet.class, FreeMarkerConfigurer.class })
@AutoConfigureAfter(WebMvcAutoConfiguration.class)
class FreeMarkerServletWebConfiguration extends AbstractFreeMarkerConfiguration {

	protected FreeMarkerServletWebConfiguration(FreeMarkerProperties properties) {
		super(properties);
	}

	@Bean
	@ConditionalOnMissingBean(FreeMarkerConfig.class)
	FreeMarkerConfigurer freeMarkerConfigurer() {
		FreeMarkerConfigurer configurer = new FreeMarkerConfigurer();
		applyProperties(configurer);
		return configurer;
	}

	@Bean
	freemarker.template.Configuration freeMarkerConfiguration(FreeMarkerConfig configurer) {
		return configurer.getConfiguration();
	}

	@Bean
	@ConditionalOnMissingBean(name = "freeMarkerViewResolver")
	@ConditionalOnProperty(name = "spring.freemarker.enabled", matchIfMissing = true)
	FreeMarkerViewResolver freeMarkerViewResolver() {
		FreeMarkerViewResolver resolver = new FreeMarkerViewResolver();
		getProperties().applyToMvcViewResolver(resolver);
		return resolver;
	}

	@Bean
	@ConditionalOnEnabledResourceChain
	@ConditionalOnMissingFilterBean(ResourceUrlEncodingFilter.class)
	FilterRegistrationBean<ResourceUrlEncodingFilter> resourceUrlEncodingFilter() {
		FilterRegistrationBean<ResourceUrlEncodingFilter> registration = new FilterRegistrationBean<>(
				new ResourceUrlEncodingFilter());
		registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
		return registration;
	}

}

我们来简单看下这段源码:

  • @ConditionalOnWebApplication 表示当前配置在 web 环境下才会生效。
  • ConditionalOnClass 表示当前配置在存在 Servlet 和 FreeMarkerConfigurer 时才会生效。
  • @AutoConfigureAfter 表示当前自动化配置在 WebMvcAutoConfiguration 之后完成。
  • 代码中,主要提供了 FreeMarkerConfigurer 和 FreeMarkerViewResolver。
  • FreeMarkerConfigurer 是 Freemarker 的一些基本配置,例如 templateLoaderPath、defaultEncoding 等
  • FreeMarkerViewResolver 则是视图解析器的基本配置,包含了viewClass、suffix、allowRequestOverride、allowSessionOverride 等属性。

另外还有一点,在这个类的构造方法中,注入了 FreeMarkerProperties:

@ConfigurationProperties(prefix = "spring.freemarker")
public class FreeMarkerProperties extends AbstractTemplateViewResolverProperties {
        public static final String DEFAULT_TEMPLATE_LOADER_PATH = "classpath:/templates/";
        public static final String DEFAULT_PREFIX = "";
        public static final String DEFAULT_SUFFIX = ".ftlh";
        /**
         * Well-known FreeMarker keys which are passed to FreeMarker's Configuration.
         */
        private Map<String, String> settings = new HashMap<>();
}

FreeMarkerProperties 中则配置了 Freemarker 的基本信息,例如模板位置在 classpath:/templates/ ,再例如模板后缀为 .ftlh,那么这些配置我们以后都可以在 application.properties 中进行修改。

如果我们在 SSM 的 XML 文件中自己配置 Freemarker ,也不过就是配置这些东西。现在,这些配置由 FreeMarkerServletWebConfiguration 帮我们完成了。

2.1.2 创建类

首先我们来创建一个 User 类,如下:

public class User {
    private Long id;
    private String username;
    private String address;
    //省略 getter/setter
}

再来创建 UserController

@Controller
public class UserController {
    @GetMapping("/index")
    public String index(Model model) {
        List<User> users = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            User user = new User();
            user.setId((long) i);
            user.setUsername("javaboy>>>>" + i);
            user.setAddress("www.javaboy.org>>>>" + i);
            users.add(user);
        }
        model.addAttribute("users", users);
        return "index";
    }
}

最后在 freemarker 中渲染数据,创建index.ftlh

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<table border="1">
    <tr>
        <td>用户编号</td>
        <td>用户名称</td>
        <td>用户地址</td>
    </tr>
    <#list users as user>
        <tr>
            <td>${user.id}</td>
            <td>${user.username}</td>
            <td>${user.address}</td>
        </tr>
    </#list>
</table>
</body>
</html>

运行效果如下:

img

2.1.3 其他配置

如果我们要修改模版文件位置等,可以在 application.properties 中进行配置:

spring.freemarker.allow-request-override=false
spring.freemarker.allow-session-override=false
spring.freemarker.cache=false
spring.freemarker.charset=UTF-8
spring.freemarker.check-template-location=true
spring.freemarker.content-type=text/html
spring.freemarker.expose-request-attributes=false
spring.freemarker.expose-session-attributes=false
spring.freemarker.suffix=.ftl
spring.freemarker.template-loader-path=classpath:/templates/

配置文件按照顺序依次解释如下:

  • HttpServletRequest的属性是否可以覆盖controller中model的同名项
  • HttpSession的属性是否可以覆盖controller中model的同名项
  • 是否开启缓存
  • 模板文件编码
  • 是否检查模板位置
  • Content-Type的值
  • 是否将HttpServletRequest中的属性添加到Model中
  • 是否将HttpSession中的属性添加到Model中
  • 模板文件后缀
  • 模板文件位置

好了,整合完成之后,Freemarker 的更多用法,就和在 SSM 中使用 Freemarker 一样了

2.2 Freemarker 使用细节

2.2.1 插值与表达式

直接输出值

字符串

可以直接输出一个字符串:

<div>${"hello,我是直接输出的字符串"}</div>
<div>${"我的文件保存在C:\\盘"}</div>

\ 需要进行转义。

如果感觉转义太麻烦,可以在目标字符串的引号前增加 r 标记,在 r 标记后的文本内容将会直接输出,像下面这样:

<div>${r"我的文件保存在C:\盘"}</div>

数字

在 FreeMarker 中使用数值需要注意以下几点:

  1. 数值不能省略小数点前面的0,所以”.5”是错误的写法。
  2. 数值 8 , +8 , 8.00 都是相同的。

数字还有一些其他的玩法:

  • 将数字以钱的形式展示:
<#assign num=99>
<div>${num?string.currency}</div>

<#assign num=99> 表示定义了一个变量 num,值为 99。最终的展示形式是在数字前面出现了一个人民币符号:

img

  • 将数字以百分数的形式展示:
<div>${num?string.percent}</div>

展示效果如下:

img

布尔

布尔类型可以直接定义,不需要引号,像下面这样:

<#assign flag=true>
<div>${flag?string("a","b")}</div>

首先使用 <#assign flag=true> 定义了一个 Boolean 类型的变量,然后在 div 中展示,如果 flag 为 true,则输出 a,否则输出 b。

集合

集合也可以现场定义现场输出,例如如下方式定义一个 List 集合并显示出来:

<#list ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期天"] as x>
    ${x}<br/>
</#list>

x 代表集合中的每一个元素,最终显示效果如下:

img

集合中的元素也可以是一个表达式:

<#list [2+2,"javaboy"] as x>
    ${x} <br/>
</#list>

集合中的第一个元素就是 2+2 的结果,即 4。

也可以用 1..5 表示 1 到 5,5..1 表示 5 到 1,例如:

<#list 5..1 as x>
    ${x} <br/>
</#list>
<#list 1..5 as x>
    ${x} <br/>
</#list>

也可以定义 Map 集合,Map 集合用一个 {} 来描述:

<#list {"name":"javaboy","address":"www.javaboy.org"}?keys as x>
${x}
</#list>
<#list {"name":"javaboy","address":"www.javaboy.org"}?values as x>
${x}
</#list>

上面两个循环分别表示遍历 Map 中的 key 和 values。

输出变量

创建一个 HelloController,然后添加如下方法:

@Controller
public class HelloController {
    @GetMapping("/hello")
    public String hello(Model model) {
        List<User> users = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            User u = new User();
            u.setUsername("javaboy:" + i);
            u.setAddress("www.javaboy.org:" + i);
            users.add(u);
        }
        Map<String, Object> info = new HashMap<>();
        info.put("site", "http://www.itboyhub.com");
        info.put("wechat", "a_java_boy");
        info.put("github", "https://github.com/lenve");
        model.addAttribute("users", users);
        model.addAttribute("info", info);
        model.addAttribute("name", "江南一点雨");
        return "hello";
    }
}

接下来我们在模版文件中对这里的普通变量、List 集合以及 Map 分别进行展示。

普通变量

普通变量的展示很容易,如下:

<div>${name}</div>

集合

集合的展示就有很多不同的玩法了。

直接遍历:

<div>
    <table border="1">
        <#list users as u>
            <tr>
                <td>${u.username}</td>
                <td>${u.address}</td>
            </tr>
        </#list>
    </table>
</div>

输出集合中第三个元素:

<div>
    ${users[3].username}
</div>

输出集合中第 4-6 个元素,即子集合:

<div>
    <table border="1">
        <#list users[3..5] as u>
            <tr>
                <td>${u.username}</td>
                <td>${u.address}</td>
            </tr>
        </#list>
    </table>
</div>

遍历时,可以通过 变量_index 获取遍历的下标:

<div>
    <table border="1">
        <#list users[3..5] as u>
            <tr>
                <td>${u.username}</td>
                <td>${u.address}</td>
                <td>${u_index}</td>
            </tr>
        </#list>
    </table>
</div>

Map

直接获取 Map 中的值有不同的写法,如下:

<div>${info.wechat}</div>
<div>${info['site']}</div>

获取 Map 中的所有 key,并根据 key 获取 value:

<div>
    <#list info?keys as key>
        <div>${key}--${info[key]}</div>
    </#list>
</div>

获取 Map 中的所有 value:

<div>
    <#list info?values as value>
        <div>${value}</div>
    </#list>
</div>
字符串操作

字符串的拼接有两种方式:

<div>${"hello ${name}"}</div>
<div>${"hello "+ name}</div>

也可以从字符串中截取子串:

<div>${name[0]}${name[1]}</div>
<div>${name[0..2]}</div>

最终显示效果如下:

img

集合操作

集合或者 Map 都可以相加。

集合相加:

<div>
    <#list ["星期一","星期二","星期三"] + ["星期四","星期五","星期六","星期天"] as x>
        ${x}
    </#list>
</div>

Map 相加:

<div>
    <#list (info+{"gitee":"https://gitee.com/lenve"})?keys as key>
        <div>${key}</div>
    </#list>
</div>
算术运算符

+*/% 运算都是支持的。

<div>
    <#assign age=99>
    <div>${age*99/99+99-1}</div>
</div>
比较运算

比较运算和 Thymeleaf 比较类似:

  • = 或者 == 判断两个值是否相等。
  • != 判断两个值是否不等。
  • > 或者 gt 判断左边值是否大于右边值。
  • >= 或者 gte 判断左边值是否大于等于右边值。
  • < 或者 lt 判断左边值是否小于右边值。
  • <= 或者 lte 判断左边值是否小于等于右边值。

可以看到,带 < 或者 > 的符号,也都有别名,建议使用别名。

<div>
    <#assign age=99>
    <#if age=99>age=99</#if>
    <#if age gt 99>age gt 99</#if>
    <#if age gte 99>age gte 99</#if>
    <#if age lt 99>age lt 99</#if>
    <#if age lte 99>age lte 99</#if>
    <#if age!=99>age!=99</#if>
    <#if age==99>age==99</#if>
</div>
逻辑运算

逻辑运算符有三个:

  • 逻辑与 &&
  • 逻辑或 ||
  • 逻辑非 !

逻辑运算符只能作用于布尔值,否则将产生错误。

<div>
    <#assign age=99>
    <#if age=99 && 1==1>age=99 && 1==1</#if>
    <#if age=99 || 1==0>age=99 || 1==0</#if>
    <#if !(age gt 99)>!(age gt 99)</#if>
</div>
空值处理

为了处理缺失变量,Freemarker 提供了两个运算符:

  1. !:指定缺失变量的默认值
  2. ??:判断某个变量是否存在

如果某个变量不存在,则设置其为 javaboy,如下:

<div>${aaa!"javaboy"}</div>

如果某个变量不存在,则设置其为空字符串,如下:

<div>${aaa!}</div>

即,! 后面的东西如果省略了,默认就是空字符串。

判断某个变量是否存在:

<div>${aaa!"javaboy"}</div>
<div>${aaa!}</div>
<div>
  <#if aaa??>
    aaa
    </#if>
</div>

2.2.2 内建函数

内建函数涉及到的东西比较多,可以参考官方文档:http://freemarker.foofun.cn/ref_builtins.html。

这里仅说一些比较常用的内建函数。

cap_first

使字符串第一个字母大写:

<div>${"hello"?cap_first}</div>

lower_case

将字符串转换成小写:

<div>${"HELLO"?lower_case}</div>

upper_case

将字符串转换成大写:

<div>${"hello"?upper_case}</div>

trim

去掉字符串前后的空白字符:

<div>${" hello"?trim}</div>

size

获取序列中元素的个数:

<div>${users?size}</div>

int

取得数字的整数部分,结果带符号:

<div>${3.14?int}</div>

日期格式化

<div>${birthday?string("yyyy-MM-dd")}</div>

2.2.3 常用指令

if/else

分支控制指令,作用类似于 Java 语言中的 if:

<div>
    <#assign age=23>
    <#if (age>60)>老年人
    <#elseif (age>40)>中年人
    <#elseif (age>20)>青年人
    <#else> 少年人
    </#if>
</div>

比较符号中用了 (),因此不用转义。

switch

分支指令,类似于 Java 中的 switch:

<div>
    <#assign age=99>
    <#switch age>
        <#case 23>23<#break>
        <#case 24>24<#break>
        <#default>9999
    </#switch>
</div>

<#break> 是提前退出,也可以用在 <#list> 中。

include

include 可以包含一个外部页面进来。

<#include "./javaboy.ftlh">
macro

macro 用来定义一个宏。

我们可以自定义一个名为 book 的宏,并引用它:

<#macro book>
    三国演义
</#macro>
<@book/>

最终页面中会输出宏中所定义的内容。

在定义宏的时候,也可以传入参数,那么引用时,也需要传入参数:

<#macro book bs>
    <table border="1">
        <#list bs as b>
            <tr>
                <td>${b}</td>
            </tr>
        </#list>
    </table>
</#macro>
<@book ["三国演义","水浒传"]/>

bs 就是需要传入的参数。可以通过传入多个参数,多个参数跟在 bs 后面即可,中间用空格隔开。

还可以使用 <#nested> 引入用户自定义指令的标签体,像下面这样:

<#macro book bs>
    <table border="1">
        <#list bs as b>
            <tr>
                <td>${b}</td>
            </tr>
        </#list>
    </table>
    <#nested>
</#macro>
<@book ["三国演义","水浒传"]>
    <h1>hello javaboy!</h1>
</@book>

在宏定义的时候,<#nested> 相当于是一个占位符,在调用的时候,<@book> 标签中的内容会出现在 <#nested> 位置。

前面的案例中,宏都是定义在当前页面中,宏也可以定义在一个专门的页面中。新建myjavaboy.ftlh页面,内容如下:

<#macro book bs>
    <table border="1">
        <#list bs as b>
            <tr>
                <td>${b}</td>
            </tr>
        </#list>
    </table>
    <#nested>
</#macro>

此时,需要先通过 <#import> 标签导入宏,然后才能调用,如下:

<#import "./myjavaboy.ftlh" as com>
<@com.book bs=["三国演义","水浒传"]>
    <h1>hello javaboy!</h1>
</@com.book>
noparse

如果想在页面展示一些 Freemarker 语法而不被渲染,则可以使用 noparse 标签,如下:

<#noparse>
<#import "./myjavaboy.ftlh" as com>
<@com.book bs=["三国演义","水浒传"]>
    <h1>hello javaboy!</h1>
</@com.book>
</#noparse>

显示效果如下:

img

如果要同时使用Thymeleaf,要把Freemarker的pom依赖放在前面

SpringBoot整合Web

1. SpringBoot中的JSON

具体的JSON用法可以[参考SringMVC](#11. JSON)中的介绍,这里补充几点

1.1 Jackson

SpringBoot默认就有,不需要额外引入pom.xml的依赖

//批量忽略字段
@JsonIgnoreProperties({"birthday","address"})
public class User {
    //指定属性序列化/反序列化时的名称,默认名称就是属性名
    @JsonProperty(value = "aaaage",index = 99)
    private Integer age;
    @JsonProperty(index = 98)
    private String username;
		@JsonProperty(index = 97)
    //日期格式化,注意时区问题
		@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "Asia/Shanghai")
    //类似于 @JsonProperty 中的 index
    @JsonPropertyOrder
    private Date birthday;

    //序列化/反序列化时忽略某一个字段
		@JsonIgnore
    private String address;

自己提供一个ObjectMapper实例,就不用每个实体类上添加

@Configuration
public class WebMvcConfig {
    @Bean
    ObjectMapper objectMapper() {
        ObjectMapper om = new ObjectMapper();
        om.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
        return om;
    }
}

1.2 Gson

pom.xml里排除默认的依赖再引入

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-json</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<dependency>
  <groupId>com.google.code.gson</groupId>
  <artifactId>gson</artifactId>
</dependency>

application.properties里的配置

spring.gson.date-format=yyyy-MM-dd HH:mm:ss
## 是否禁用 HTML 的转义字符
spring.gson.disable-html-escaping=true
## 序列化时是否排除内部类
spring.gson.disable-inner-class-serialization=false
## 序列化时是否弃用复杂映射键
spring.gson.enable-complex-map-key-serialization=
## 是否排除没有 @Expose 注解的字段
spring.gson.exclude-fields-without-expose-annotation=
## 序列化时字段名的命名策略
spring.gson.field-naming-policy=
## 在输出之前添加一些特殊的文本来生成一个不可执行的 JSON
spring.gson.generate-non-executable-json=
## 是否序列化空字段
spring.gson.serialize-nulls=
@RestController
public class UserController {
    @GetMapping("/user")
    public User getUser() {
        User user = new User();
        user.setUsername("javaboy");
        user.setBirthday(new Date());
        return user;
    }
}


//同理于jackson的ObjectMapper
@Configuration
public class WebMvcConfig {
    @Bean
    GsonBuilder gsonBuilder() {
        GsonBuilder gsonBuilder = new GsonBuilder();
        gsonBuilder.setDateFormat("yyyy-MM-dd");
        return gsonBuilder;
    }
  
//或者
    @Bean
    GsonHttpMessageConverter gsonHttpMessageConverter() {
        GsonBuilder gsonBuilder = new GsonBuilder();
        gsonBuilder.setDateFormat("yyyy-MM-dd");
        GsonHttpMessageConverter converter = new GsonHttpMessageConverter();
        converter.setGson(gsonBuilder.create());
        return converter;
    }
}

1.3 fastjson

pom.xml里排除默认的依赖再引入

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <exclusions>
    <exclusion>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-json</artifactId>
    </exclusion>
  </exclusions>
</dependency>

<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>fastjson</artifactId>
  <version>1.2.75</version>
</dependency>

Spring没有给它提供FastJsonHttpMessageConverter,必须要自己提供

@Configuration
public class WebMvcConfig {
    @Bean
    FastJsonHttpMessageConverter fastJsonHttpMessageConverter() {
        FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter();
        FastJsonConfig fastJsonConfig = new FastJsonConfig();
        fastJsonConfig.setCharset(Charset.forName("UTF-8"));
        fastJsonConfig.setDateFormat("yyyy-MM-dd HH:mm:ss");
        converter.setFastJsonConfig(fastJsonConfig);
        converter.setDefaultCharset(Charset.forName("UTF-8"));
        return converter;
    }
}

//或者
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter();
        FastJsonConfig fastJsonConfig = new FastJsonConfig();
        fastJsonConfig.setCharset(Charset.forName("UTF-8"));
        fastJsonConfig.setDateFormat("yyyy-MM-dd HH:mm:ss");
        converter.setFastJsonConfig(fastJsonConfig);
        converter.setDefaultCharset(Charset.forName("UTF-8"));
        converters.add(converter);
    }
}

2. 静态资源配置

2.1 SSM 中的配置

要讲 Spring Boot 中的问题,我们得先回到 SSM 环境搭建中,一般来说,我们可以通过 <mvc:resources /> 节点来配置不拦截静态资源,如下:

<mvc:resources mapping="/js/**" location="/js/"/>
<mvc:resources mapping="/css/**" location="/css/"/>
<mvc:resources mapping="/html/**" location="/html/"/>

由于这是一种Ant风格的路径匹配符,/** 表示可以匹配任意层级的路径,因此上面的代码也可以像下面这样简写:

<mvc:resources mapping="/**" location="/"/>

这种配置是在 XML 中的配置,大家知道,SpringMVC 的配置除了在XML中配置,也可以在 Java 代码中配置,如果在Java代码中配置的话,我们只需要自定义一个类,继承自WebMvcConfigurationSupport即可:

@Configuration
@ComponentScan(basePackages = "org.sang.javassm")
public class SpringMVCConfig extends WebMvcConfigurationSupport {
    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**").addResourceLocations("/");
    }
}

重写 WebMvcConfigurationSupport 类中的addResourceHandlers方法,在该方法中配置静态资源位置即可,这里的含义和上面 xml 配置的含义一致,因此无需多说。
这是我们传统的解决方案,在Spring Boot 中,其实配置方式和这个一脉相承,只是有一些自动化的配置了。

2.2 Spring Boot 中的配置

在 Spring Boot 中,如果我们是从 https://start.spring.io 这个网站上创建的项目,或者使用 IntelliJ IDEA 中的 Spring Boot 初始化工具创建的项目,默认都会存在resources/static目录,很多小伙伴也知道静态资源只要放到这个目录下,就可以直接访问,除了这里还有没有其他可以放静态资源的位置呢?为什么放在这里就能直接访问了呢?这就是本文要讨论的问题了。

2.2.1 整体规划

首先,在 Spring Boot 中,默认情况下,一共有5个位置可以放静态资源,五个路径分别是如下5个:

  • classpath:/META-INF/resources/
  • classpath:/resources/
  • classpath:/static/
  • classpath:/public/
  • /

前四个目录好理解,分别对应了resources目录下不同的目录,第5个 / 是啥意思呢?我们知道,在 Spring Boot 项目中,默认是没有 webapp 这个目录的,当然我们也可以自己添加(例如在需要使用JSP的时候),这里第5个 /其实就是表示 webapp 目录中的静态资源也不被拦截。如果同一个文件分别出现在五个目录下,那么优先级也是按照上面列出的顺序。

不过,虽然有5个存储目录,除了第5个用的比较少之外,其他四个,系统默认创建了 classpath:/static/ , 正常情况下,我们只需要将我们的静态资源放到这个目录下即可,也不需要额外去创建其他静态资源目录,例如我在 classpath:/static/ 目录下放了一张名为1.png 的图片,那么我的访问路径是:

http://localhost:8080/1.png

这里大家注意,请求地址中并不需要 static,如果加上了static反而多此一举会报404错误。很多人会觉得奇怪,为什么不需要添加 static呢?资源明明放在 static 目录下。其实这个效果很好实现,例如在SSM配置中,我们的静态资源拦截配置如果是下面这样:

<mvc:resources mapping="/**" location="/static/"/>

如果我们是这样配置的话,请求地址如果是 http://localhost:8080/1.png 实际上系统会去 /static/1.png 目录下查找相关的文件。

所以我们理所当然的猜测,在 Spring Boot 中可能也是类似的配置。

2.2.2 源码解读

胡适之先生说:“大胆猜想,小心求证”,我们这里就通过源码解读来看看 Spring Boot 中的静态资源到底是怎么配置的。

首先我们在 WebMvcAutoConfiguration 类中看到了 SpringMVC 自动化配置的相关的内容,找到了静态资源拦截的配置,如下:

img

可以看到这里静态资源的定义和我们前面提到的Java配置SSM中的配置非常相似,其中,this.mvcProperties.getStaticPathPattern() 方法对应的值是 “/**”this.resourceProperties.getStaticLocations()方法返回了四个位置,分别是:”classpath:/META-INF/resources/”, “classpath:/resources/”,”classpath:/static/”, “classpath:/public/”,然后在getResourceLocations方法中,又添加了“/”,因此这里返回值一共有5个。其中,/表示webapp目录,即webapp中的静态文件也可以直接访问。静态资源的匹配路径按照定义路径优先级依次降低。因此这里的配置和我们前面提到的如出一辙。这样大伙就知道了为什么Spring Boot 中支持5个静态资源位置,同时也明白了为什么静态资源请求路径中不需要/static,因为在路径映射中已经自动的添加上了/static了。

2.2.3 自定义配置

当然,这个是系统默认配置,如果我们并不想将资源放在系统默认的这五个位置上,也可以自定义静态资源位置和映射,自定义的方式也有两种,可以通过 application.properties 来定义,也可以在 Java 代码中来定义,下面分别来看。

  • application.properties

在配置文件中定义的方式比较简单,如下:

spring.resources.static-locations=classpath:/
spring.mvc.static-path-pattern=/**

第一行配置表示定义资源位置,第二行配置表示定义请求 URL 规则。以上文的配置为例,如果我们这样定义了,表示可以将静态资源放在 resources目录下的任意地方,我们访问的时候当然也需要写完整的路径,例如在resources/static目录下有一张名为1.png 的图片,那么访问路径就是 http://localhost:8080/static/1.png ,注意此时的static不能省略。

  • Java 代码定义

当然,在Spring Boot中我们也可以通过 Java代码来自定义,方式和 Java 配置的 SSM 比较类似,如下:

@Configuration
public class WebMVCConfig implements WebMvcConfigurer {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**").addResourceLocations("classpath:/aaa/");
    }
}

这里需要提醒大家的是,有很多人用了 Thymeleaf 之后,会将静态资源也放在 resources/templates 目录下,注意,templates 目录并不是静态资源目录,它是一个放页面模板的位置(你看到的 Thymeleaf 模板虽然后缀为 .html,其实并不是静态资源)

2.3 路径映射

利用Controller跳转

@Controller
public class HelloController {
    @GetMapping("/01")
    public String hello01(Model model) {

        return "01";
    }
}

利用配置类直接进行

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/02").setViewName("02");
    }
}

这样就可以在不创建controller的情况下,在url中直接输入02跳转到02.html页面了,但是此方法不支持跳转到动态渲染的页面上,具有一定的局限性

2.4 自定义首页

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/index").setViewName("index");

    }
}

默认访问的是static下的index.html,如果下面没有,去访问templates下的index.html

如果要定义网站favicon,放置favicon.ico放在static下即可

3. 文件上传

Spring自带的是StandardServletMultipartResolver,创建Controller

@RestController
public class FileUploadController {
    //分类处理
    SimpleDateFormat sdf = new SimpleDateFormat("/yyyy/MM/dd/");
    @PostMapping("/upload")
    //file要和html的name值对应
    public String upload(MultipartFile file, HttpServletRequest req) {
        //临时目录
        String realPath = req.getServletContext().getRealPath("/");
        String format = sdf.format(new Date());
        String path = realPath + format;
        File folder = new File(path);
        //判空处理
        if (!folder.exists()) {
            folder.mkdirs();
        }
        //名字后缀
        String oldName = file.getOriginalFilename();
        //前缀
        String newName = UUID.randomUUID().toString() + oldName.substring(oldName.lastIndexOf("."));
        try {
            //拼接
            file.transferTo(new File(folder, newName));
            //动态获取
            String s = req.getScheme() + "://" + req.getServerName() + ":" + req.getServerPort() + format + newName;
            return s;
        } catch (IOException e) {
            e.printStackTrace();
        }
        return "";
    }
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/upload" method="post" enctype="multipart/form-data">
<!--name属性必须写上,同时要和controller里的对应-->
    <input type="file" name="file">
    <input type="submit" value="上传">
</form>
</body>
</html>
#设置上传文件大小
spring.servlet.multipart.max-file-size=1KB

如果要上传多文件,配置如下

@RestController
public class FileUploadController2 {
    SimpleDateFormat sdf = new SimpleDateFormat("/yyyy/MM/dd/");

    @PostMapping("/upload2")
    public void upload(MultipartFile[] files, HttpServletRequest req) {
        String realPath = req.getServletContext().getRealPath("/");
        String format = sdf.format(new Date());
        String path = realPath + format;
        File folder = new File(path);
        if (!folder.exists()) {
            folder.mkdirs();
        }
        try {
            for (MultipartFile file : files) {
                String oldName = file.getOriginalFilename();
                String newName = UUID.randomUUID().toString() + oldName.substring(oldName.lastIndexOf("."));
                file.transferTo(new File(folder, newName));
                String s = req.getScheme() + "://" + req.getServerName() + ":" + req.getServerPort() + format + newName;
                System.out.println("s = " + s);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/upload2" method="post" enctype="multipart/form-data">
    <input type="file" name="files" multiple>
    <input type="submit" value="上传">
</form>
</body>
</html>

ajax的文件上传

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="https://code.jquery.com/jquery-3.5.1.js" integrity="sha256-QWo7LDvxbWT2tbbQ97B53yJnYU3WhH/C8ycbRAkjPDc=" crossorigin="anonymous"></script>
</head>
<body>
<div id="result"></div>
<input type="file" id="file">
<input type="button" value="上传" onclick="uploadFile()">
<script>
    function uploadFile() {
        //拿到文件对象
        var file = $("#file")[0].files[0];
        var formData = new FormData();
        formData.append("file", file);
        formData.append("username", "javaboy");
        $.ajax({
            type:'post',
            url:'/upload',
            processData:false,
            contentType:false,
            data:formData,
            success:function (msg) {
                $("#result").html(msg);
            }
        })
    }
</script>
</body>
</html>

4. 异常处理

在 Spring Boot 项目中 ,异常统一处理,可以使用 Spring 中 @ControllerAdvice 来统一处理,也可以自己来定义异常处理方案。Spring Boot 中,对异常的处理有一些默认的策略,我们分别来看。

默认情况下,Spring Boot 中的异常页面 是这样的:

img

我们从这个异常提示中,也能看出来,之所以用户看到这个页面,是因为开发者没有明确提供一个 /error 路径,如果开发者提供了 /error 路径 ,这个页面就不会展示出来,不过在 Spring Boot 中,提供 /error 路径实际上是下下策,Spring Boot 本身在处理异常时,也是当所有条件都不满足时,才会去找 /error 路径。那么我们就先来看看,在 Spring Boot 中,如何自定义 error 页面,整体上来说,可以分为两种,一种是静态页面,另一种是动态页面。

4.1 静态异常页面

自定义静态异常页面,又分为两种,第一种 是使用 HTTP 响应码来命名页面,例如 404.html、405.html、500.html ….,另一种就是直接定义一个 4xx.html,表示400-499 的状态都显示这个异常页面,5xx.html 表示 500-599 的状态显示这个异常页面。

默认是在 classpath:/static/error/ 路径下定义相关页面:

img

此时,启动项目,如果项目抛出 500 请求错误,就会自动展示 500.html 这个页面,发生 404 就会展示 404.html 页面。如果异常展示页面既存在 5xx.html,也存在 500.html ,此时,发生500异常时,优先展示 500.html 页面

4.2 动态异常页面

动态的异常页面定义方式和静态的基本 一致,可以采用的页面模板有 jsp、freemarker、thymeleaf。动态异常页面,也支持 404.html 或者 4xx.html ,但是一般来说,由于动态异常页面可以直接展示异常详细信息,所以就没有必要挨个枚举错误了 ,直接定义 4xx.html(这里使用thymeleaf模板)或者 5xx.html 即可。

注意,动态页面模板,不需要开发者自己去定义控制器,直接定义异常页面即可 ,Spring Boot 中自带的异常处理器会自动查找到异常页面。

页面定义如下:

img

页面内容如下:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>5xx</h1>
<table border="1">
    <tr>
        <td>path</td>
        <td th:text="${path}"></td>
    </tr>
    <tr>
        <td>error</td>
        <td th:text="${error}"></td>
    </tr>
    <tr>
        <td>message</td>
        <td th:text="${message}"></td>
    </tr>
    <tr>
        <td>timestamp</td>
        <td th:text="${timestamp}"></td>
    </tr>
    <tr>
        <td>status</td>
        <td th:text="${status}"></td>
    </tr>
</table>
</body>
</html>

默认情况下,完整的异常信息就是这5条,展示 效果如下 :

img

如果动态页面和静态页面同时定义了异常处理页面,例如 classpath:/static/error/404.htmlclasspath:/templates/error/404.html 同时存在时,默认使用动态页面。即完整的错误页面查找方式应该是这样:

发生了500错误–>查找动态 500.html 页面–>查找静态 500.html –> 查找动态 5xx.html–>查找静态 5xx.html

4.3 自定义异常数据

默认情况下,在Spring Boot 中,所有的异常数据其实就是上文所展示出来的5条数据,这5条数据定义在 org.springframework.boot.web.reactive.error.DefaultErrorAttributes 类中,具体定义在 getErrorAttributes 方法中 :

@Override
public Map<String, Object> getErrorAttributes(ServerRequest request,
                boolean includeStackTrace) {
        Map<String, Object> errorAttributes = new LinkedHashMap<>();
        errorAttributes.put("timestamp", new Date());
        errorAttributes.put("path", request.path());
        Throwable error = getError(request);
        HttpStatus errorStatus = determineHttpStatus(error);
        errorAttributes.put("status", errorStatus.value());
        errorAttributes.put("error", errorStatus.getReasonPhrase());
        errorAttributes.put("message", determineMessage(error));
        handleException(errorAttributes, determineException(error), includeStackTrace);
        return errorAttributes;
}

DefaultErrorAttributes 类本身则是在org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration 异常自动配置类中定义的,如果开发者没有自己提供一个 ErrorAttributes 的实例的话,那么 Spring Boot 将自动提供一个ErrorAttributes 的实例,也就是 DefaultErrorAttributes

基于此 ,开发者自定义 ErrorAttributes 有两种方式 :

  • 直接实现 ErrorAttributes 接口
  • 继承 DefaultErrorAttributes(推荐),因为DefaultErrorAttributes中对异常数据的处理已经完成,开发者可以直接使用。

具体定义如下:

@Component
public class MyErrorAttributes  extends DefaultErrorAttributes {
    @Override
    public Map<String, Object> getErrorAttributes(WebRequest webRequest, boolean includeStackTrace) {
        Map<String, Object> map = super.getErrorAttributes(webRequest, includeStackTrace);
        if ((Integer)map.get("status") == 500) {
            map.put("message", "服务器内部错误!");
        }
        return map;
    }
}

定义好的ErrorAttributes一定要注册成一个 Bean ,这样,Spring Boot 就不会使用默认的 DefaultErrorAttributes 了,运行效果如下图:

img

4.4 自定义异常视图

异常视图默认就是前面所说的静态或者动态页面,这个也是可以自定义的,首先 ,默认的异常视图加载逻辑在 org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController 类的 errorHtml 方法中,这个方法用来返回异常页面+数据,还有另外一个 error 方法,这个方法用来返回异常数据(如果是 ajax 请求,则该方法会被触发)。

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request,
                HttpServletResponse response) {
        HttpStatus status = getStatus(request);
        Map<String, Object> model = Collections.unmodifiableMap(getErrorAttributes(
                        request, isIncludeStackTrace(request, MediaType.TEXT_HTML)));
        response.setStatus(status.value());
        ModelAndView modelAndView = resolveErrorView(request, response, status, model);
        return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
}

在该方法中 ,首先会通过 getErrorAttributes 方法去获取异常数据(实际上会调用到 ErrorAttributes 的实例 的 getErrorAttributes 方法),然后调用 resolveErrorView 去创建一个 ModelAndView ,如果这里创建失败,那么用户将会看到默认的错误提示页面。

正常情况下,resolveErrorView方法会来到 DefaultErrorViewResolver 类的 resolveErrorView 方法中:

@Override
public ModelAndView resolveErrorView(HttpServletRequest request, HttpStatus status,
                Map<String, Object> model) {
        ModelAndView modelAndView = resolve(String.valueOf(status.value()), model);
        if (modelAndView == null && SERIES_VIEWS.containsKey(status.series())) {
                modelAndView = resolve(SERIES_VIEWS.get(status.series()), model);
        }
        return modelAndView;
}

在这里,首先以异常响应码作为视图名分别去查找动态页面和静态页面,如果没有查找到,则再以 4xx 或者 5xx 作为视图名再去分别查找动态或者静态页面。

要自定义异常视图解析,也很容易 ,由于DefaultErrorViewResolver是在 ErrorMvcAutoConfiguration 类中提供的实例,即开发者没有提供相关实例时,会使用默认的 DefaultErrorViewResolver ,开发者提供了自己的 ErrorViewResolver 实例后,默认的配置就会失效,因此,自定义异常视图,只需要提供 一个 ErrorViewResolver 的实例即可:

@Component
public class MyErrorViewResolver extends DefaultErrorViewResolver {
    public MyErrorViewResolver(ApplicationContext applicationContext, ResourceProperties resourceProperties) {
        super(applicationContext, resourceProperties);
    }
    @Override
    public ModelAndView resolveErrorView(HttpServletRequest request, HttpStatus status, Map<String, Object> model) {
        return new ModelAndView("/aaa/123", model);
    }
}

实际上,开发者也可以在这里定义异常数据(直接在 resolveErrorView 方法重新定义一个 model ,将参数中的model 数据拷贝过去并修改,注意参数中的 model 类型为 UnmodifiableMap,即不可以直接修改),而不需要自定义MyErrorAttributes。定义完成后,提供一个名为123的视图,如下图:

img

如此之后,错误试图就算定义成功了。

5. CORS解决跨域

域:协议+域名/IP/端口

5.1 同源策略

很多人对跨域有一种误解,以为这是前端的事,和后端没关系,其实不是这样的,说到跨域,就不得不说说浏览器的同源策略。
同源策略是由Netscape提出的一个著名的安全策略,它是浏览器最核心也最基本的安全功能,现在所有支持JavaScript的浏览器都会使用这个策略。所谓同源是指协议、域名以及端口要相同。同源策略是基于安全方面的考虑提出来的,这个策略本身没问题,但是我们在实际开发中,由于各种原因又经常有跨域的需求,传统的跨域方案是JSONP,JSONP虽然能解决跨域但是有一个很大的局限性,那就是只支持GET请求,不支持其他类型的请求,而今天我们说的CORS(跨域源资源共享)(CORS,Cross-origin resource sharing)是一个W3C标准,它是一份浏览器技术的规范,提供了Web服务从不同网域传来沙盒脚本的方法,以避开浏览器的同源策略,这是JSONP模式的现代版。
在Spring框架中,对于CORS也提供了相应的解决方案,今天我们就来看看SpringBoot中如何实现CORS。

5.2 实践

首先创建两个普通的SpringBoot项目,第一个命名为provider提供服务,第二个命名为consumer消费服务,第一个配置端口为8080,第二个配置配置为8081,然后在provider上提供两个hello接口,一个get,一个post,如下:

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
    @PostMapping("/hello")
    public String hello2() {
        return "post hello";
    }
}

consumerresources/static目录下创建一个html文件,发送一个简单的ajax请求,如下:

<div id="app"></div>
<input type="button" onclick="btnClick()" value="get_button">
<input type="button" onclick="btnClick2()" value="post_button">
<script>
    function btnClick() {
        $.get('http://localhost:8080/hello', function (msg) {
            $("#app").html(msg);
        });
    }

    function btnClick2() {
        $.post('http://localhost:8080/hello', function (msg) {
            $("#app").html(msg);
        });
    }
</script>

然后分别启动两个项目,发送请求按钮,观察浏览器控制台如下:

Access to XMLHttpRequest at 'http://localhost:8080/hello' from origin 'http://localhost:8081' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

可以看到,由于同源策略的限制,请求无法发送成功。

使用CORS可以在前端代码不做任何修改的情况下,实现跨域,那么接下来看看在provider中如何配置。首先可以通过@CrossOrigin注解配置某一个方法接受某一个域的请求,如下:

@RestController
public class HelloController {
    @CrossOrigin(value = "http://localhost:8081")
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }

    @CrossOrigin(value = "http://localhost:8081")
    @PostMapping("/hello")
    public String hello2() {
        return "post hello";
    }
}

这个注解表示这两个接口接受来自http://localhost:8081地址的请求,配置完成后,重启provider,再次发送请求,浏览器控制台就不会报错了,consumer也能拿到数据了

此时观察浏览器请求网络控制台,可以看到响应头中多了如下信息:
img

这个表示服务端愿意接收来自http://localhost:8081的请求,拿到这个信息后,浏览器就不会再去限制本次请求的跨域了。

provider上,每一个方法上都去加注解未免太麻烦了,在Spring Boot中,还可以通过全局配置一次性解决这个问题,全局配置只需要在配置类中重写addCorsMappings方法即可,如下:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
        .allowedOrigins("http://localhost:8081")
        .allowedMethods("*")
        .allowedHeaders("*");
    }
  
//或者
      @Bean
    CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration cfg = new CorsConfiguration();
        cfg.addAllowedOrigin("http://localhost:8081");
      	//允许options探测请求
        cfg.addAllowedMethod("*");
        source.registerCorsConfiguration("/**",cfg);
        return new CorsFilter(source);
    }


}

/**表示本应用的所有方法都会去处理跨域请求,allowedMethods表示允许通过的请求数,allowedHeaders则表示允许的请求头。经过这样的配置之后,就不必在每个方法上单独配置跨域了。

5.3 存在的问题

了解了整个CORS的工作过程之后,我们通过Ajax发送跨域请求,虽然用户体验提高了,但是也有潜在的威胁存在,常见的就是CSRF(Cross-site request forgery)跨站请求伪造。跨站请求伪造也被称为one-click attack 或者 session riding,通常缩写为CSRF或者XSRF,是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法,举个例子:

假如一家银行用以运行转账操作的URL地址如下:http://icbc.com/aa?bb=cc,那么,一个恶意攻击者可以在另一个网站上放置如下代码:<img src="http://icbc.com/aa?bb=cc">,如果用户访问了恶意站点,而她之前刚访问过银行不久,登录信息尚未过期,那么她就会遭受损失。

基于此,浏览器在实际操作中,会对请求进行分类,分为简单请求,预先请求,带凭证的请求等,预先请求会首先发送一个options探测请求,和浏览器进行协商是否接受请求。默认情况下跨域请求是不需要凭证的,但是服务端可以配置要求客户端提供凭证,这样就可以有效避免csrf攻击。

6. 拦截器

和SpringMVC的用法相同

public class MyInterceptor implements HandlerInterceptor {

    //该方法返回 false,请求将不再继续往下走
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        System.out.println("preHandle");
        return true;
    }

    //Controller 执行之后被调用
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        System.out.println("postHandle");
    }

    //preHandle 方法返回 true,afterCompletion 才会执行。
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println("afterCompletion");
    }
}

不再用xml配置,使用基于注解的配置

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new MyInterceptor()).addPathPatterns("/**").excludePathPatterns("/hello");
    }
}

就可以对Conroller进行拦截

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        System.out.println("hello");
        return "hello";
    }
    @GetMapping("/hello2")
    public String hello2() {
        System.out.println("hello2");
        return "hello2";
    }
}

7. 定义系统启动任务

在 Servlet/Jsp 项目中,如果涉及到系统任务,例如在项目启动阶段要做一些数据初始化操作,这些操作有一个共同的特点,只在项目启动时进行,以后都不再执行,这里,容易想到web基础中的三大组件( Servlet、Filter、Listener )之一 Listener ,这种情况下,一般定义一个 ServletContextListener,然后就可以监听到项目启动和销毁,进而做出相应的数据初始化和销毁操作,例如下面这样:

public class MyListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        //在这里做数据初始化操作
    }
    @Override
    public void contextDestroyed(ServletContextEvent sce) {
        //在这里做数据备份操作
    }
}

当然,这是基础 web 项目的解决方案,如果使用了 Spring Boot,那么我们可以使用更为简便的方式。Spring Boot 中针对系统启动任务提供了两种解决方案,分别是 CommandLineRunner 和 ApplicationRunner,分别来看。

7.1 CommandLineRunner

使用 CommandLineRunner 时,首先自定义 MyCommandLineRunner1 并且实现 CommandLineRunner 接口:

@Component
@Order(100)
public class MyCommandLineRunner1 implements CommandLineRunner {
    @Override
    public void run(String... args) throws Exception {
    }
}

关于这段代码,我做如下解释:

  • 首先通过 @Compoent 注解将 MyCommandLineRunner1 注册为Spring容器中的一个 Bean。
  • 添加 @Order注解,表示这个启动任务的执行优先级,因为在一个项目中,启动任务可能有多个,所以需要有一个排序。@Order 注解中,数字越小,优先级越大,默认情况下,优先级的值为 Integer.MAX_VALUE,表示优先级最低。
  • 在 run 方法中,写启动任务的核心逻辑,当项目启动时,run方法会被自动执行。
  • run 方法的参数,来自于项目的启动参数,即项目入口类中,main方法的参数会被传到这里。

此时启动项目,run方法就会被执行,至于参数,可以通过两种方式来传递,如果是在 IDEA 中,可以通过如下方式来配置参数:

img

另一种方式,则是将项目打包,在命令行中启动项目,然后启动时在命令行传入参数,如下:

java -jar devtools-0.0.1-SNAPSHOT.jar 三国演义 西游记

注意,这里参数传递时没有key,直接写value即可,执行结果如下:

img

7.2 ApplicationRunner

ApplicationRunner 和 CommandLineRunner 功能一致,用法也基本一致,唯一的区别主要体现在对参数的处理上,ApplicationRunner 可以接收更多类型的参数(ApplicationRunner 除了可以接收 CommandLineRunner 的参数之外,还可以接收 key/value形式的参数)。

使用 ApplicationRunner ,自定义类实现 ApplicationRunner 接口即可,组件注册以及组件优先级的配置都和 CommandLineRunner 一致,如下:

@Component
@Order(98)
public class MyApplicationRunner1 implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<String> nonOptionArgs = args.getNonOptionArgs();
        System.out.println("MyApplicationRunner1>>>"+nonOptionArgs);
        Set<String> optionNames = args.getOptionNames();
        for (String key : optionNames) {
            System.out.println("MyApplicationRunner1>>>"+key + ":" + args.getOptionValues(key));
        }
        String[] sourceArgs = args.getSourceArgs();
        System.out.println("MyApplicationRunner1>>>"+Arrays.toString(sourceArgs));
    }
}

当项目启动时,这里的 run 方法就会被自动执行,关于 run 方法的参数 ApplicationArguments ,我说如下几点:

  • args.getNonOptionArgs();可以用来获取命令行中的无key参数(和CommandLineRunner一样)。
  • args.getOptionNames();可以用来获取所有key/value形式的参数的key。
  • args.getOptionValues(key));可以根据key获取key/value 形式的参数的value。
  • args.getSourceArgs(); 则表示获取命令行中的所有参数。

ApplicationRunner 定义完成后,传启动参数也是两种方式,参数类型也有两种,第一种和 CommandLineRunner 一致,第二种则是 –key=value 的形式,在 IDEA 中定义方式如下:

img

或者使用 如下启动命令:

java -jar devtools-0.0.1-SNAPSHOT.jar 三国演义 西游记 --age=99

运行结果如下:

img

8. 定时任务

在 Spring + SpringMVC 环境中,一般来说,要实现定时任务,我们有两中方案,一种是使用 Spring 自带的定时任务处理器 @Scheduled 注解,另一种就是使用第三方框架 Quartz ,Spring Boot 源自 Spring+SpringMVC ,因此天然具备这两个 Spring 中的定时任务实现策略,当然也支持 Quartz,本文我们就来看下 Spring Boot 中两种定时任务的实现方式。

8.1 @Scheduled

使用 @Scheduled 非常容易,直接创建一个 Spring Boot 项目,并且添加 web 依赖 spring-boot-starter-web,项目创建成功后,添加 @EnableScheduling 注解,开启定时任务:

@SpringBootApplication
@EnableScheduling
public class ScheduledApplication {

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

}

接下来配置定时任务:

@Scheduled(fixedRate = 2000)
public void fixedRate() {
  System.out.println("fixedRate>>>"+new Date());    
}
@Scheduled(fixedDelay = 2000)
public void fixedDelay() {
  System.out.println("fixedDelay>>>"+new Date());
}
@Scheduled(initialDelay = 2000,fixedDelay = 2000)
public void initialDelay() {
  System.out.println("initialDelay>>>"+new Date());
}
  • 首先使用 @Scheduled 注解开启一个定时任务。
  • fixedRate 表示任务执行之间的时间间隔,具体是指两次任务的开始时间间隔,即第二次任务开始时,第一次任务可能还没结束。
  • fixedDelay 表示任务执行之间的时间间隔,具体是指本次任务结束到下次任务开始之间的时间间隔。
  • initialDelay 表示首次任务启动的延迟时间。
  • 所有时间的单位都是毫秒。

上面这是一个基本用法,除了这几个基本属性之外,@Scheduled 注解也支持 cron 表达式,使用 cron 表达式,可以非常丰富的描述定时任务的时间。cron 表达式格式如下:

[秒] [分] [小时] [日] [月] [周] [年]

具体取值如下:

序号 说明 是否必填 允许填写的值 允许的通配符
1 0-59 - * /
2 0-59 - * /
3 0-23 - * /
4 1-31 - * ? / L W
5 1-12 or JAN-DEC - * /
6 1-7 or SUN-SAT - * ? / L #
7 1970-2099 - * /

这一块需要大家注意的是,月份中的日期和星期可能会起冲突,因此在配置时这两个得有一个是 ?

通配符含义:

  • ? 表示不指定值,即不关心某个字段的取值时使用。需要注意的是,月份中的日期和星期可能会起冲突,因此在配置时这两个得有一个是 ?
  • * 表示所有值,例如:在秒的字段上设置 *,表示每一秒都会触发
  • , 用来分开多个值,例如在周字段上设置 “MON,WED,FRI” 表示周一,周三和周五触发
  • - 表示区间,例如在秒上设置 “10-12”,表示 10,11,12秒都会触发
  • / 用于递增触发,如在秒上面设置”5/15” 表示从5秒开始,每增15秒触发(5,20,35,50)
  • # 序号(表示每月的第几个周几),例如在周字段上设置”6#3”表示在每月的第三个周六,(用 在母亲节和父亲节再合适不过了)
  • 周字段的设置,若使用英文字母是不区分大小写的 ,即 MON 与mon相同
  • L 表示最后的意思。在日字段设置上,表示当月的最后一天(依据当前月份,如果是二月还会自动判断是否是润年), 在周字段上表示星期六,相当于”7”或”SAT”(注意周日算是第一天)。如果在”L”前加上数字,则表示该数据的最后一个。例如在周字段上设置”6L”这样的格式,则表示”本月最后一个星期五”
  • W 表示离指定日期的最近工作日(周一至周五),例如在日字段上设置”15W”,表示离每月15号最近的那个工作日触发。如果15号正好是周六,则找最近的周五(14号)触发, 如果15号是周未,则找最近的下周一(16号)触发,如果15号正好在工作日(周一至周五),则就在该天触发。如果指定格式为 “1W”,它则表示每月1号往后最近的工作日触发。如果1号正是周六,则将在3号下周一触发。(注,”W”前只能设置具体的数字,不允许区间”-“)
  • LW 可以一组合使用。如果在日字段上设置”LW”,则表示在本月的最后一个工作日触发(一般指发工资 )

例如,在 @Scheduled 注解中来一个简单的 cron 表达式,每隔5秒触发一次,如下:

@Scheduled(cron = "0/5 * * * * *")
public void cron() {
    System.out.println(new Date());
}

上面介绍的是使用 @Scheduled 注解的方式来实现定时任务,接下来我们再来看看如何使用 Quartz 实现定时任务。

8.2 Quartz

一般在项目中,除非定时任务涉及到的业务实在是太简单,使用 @Scheduled 注解来解决定时任务,否则大部分情况可能都是使用 Quartz 来做定时任务。在 Spring Boot 中使用 Quartz ,只需要在创建项目时,添加 Quartz 依赖即可:

img

项目创建完成后,也需要添加开启定时任务的注解:

@SpringBootApplication
@EnableScheduling
public class QuartzApplication {
    public static void main(String[] args) {
        SpringApplication.run(QuartzApplication.class, args);
    }
}

Quartz 在使用过程中,有两个关键概念,一个是JobDetail(要做的事情),另一个是触发器(什么时候做),要定义 JobDetail,需要先定义 Job,Job 的定义有两种方式:

第一种方式,直接定义一个Bean:

@Component
public class MyJob1 {
    public void sayHello() {
        System.out.println("MyJob1>>>"+new Date());
    }
}

关于这种定义方式说两点:

  1. 首先将这个 Job 注册到 Spring 容器中。
  2. 这种定义方式有一个缺陷,就是无法传参。

第二种定义方式,则是继承 QuartzJobBean 并实现默认的方法:

public class MyJob2 extends QuartzJobBean {
    HelloService helloService;
    public HelloService getHelloService() {
        return helloService;
    }
    public void setHelloService(HelloService helloService) {
        this.helloService = helloService;
    }
    @Override
    protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        helloService.sayHello();
    }
}
public class HelloService {
    public void sayHello() {
        System.out.println("hello service >>>"+new Date());
    }
}

和第1种方式相比,这种方式支持传参,任务启动时,executeInternal 方法将会被执行。

Job 有了之后,接下来创建类,配置 JobDetail 和 Trigger 触发器,如下:

@Configuration
public class QuartzConfig {
    @Bean
    MethodInvokingJobDetailFactoryBean methodInvokingJobDetailFactoryBean() {
        MethodInvokingJobDetailFactoryBean bean = new MethodInvokingJobDetailFactoryBean();
        bean.setTargetBeanName("myJob1");
        bean.setTargetMethod("sayHello");
        return bean;
    }
    @Bean
    JobDetailFactoryBean jobDetailFactoryBean() {
        JobDetailFactoryBean bean = new JobDetailFactoryBean();
        bean.setJobClass(MyJob2.class);
        JobDataMap map = new JobDataMap();
        map.put("helloService", helloService());
        bean.setJobDataMap(map);
        return bean;
    }
    @Bean
    SimpleTriggerFactoryBean simpleTriggerFactoryBean() {
        SimpleTriggerFactoryBean bean = new SimpleTriggerFactoryBean();
        bean.setStartTime(new Date());
        bean.setRepeatCount(5);
        bean.setJobDetail(methodInvokingJobDetailFactoryBean().getObject());
        bean.setRepeatInterval(3000);
        return bean;
    }
    @Bean
    CronTriggerFactoryBean cronTrigger() {
        CronTriggerFactoryBean bean = new CronTriggerFactoryBean();
        bean.setCronExpression("0/10 * * * * ?");
        bean.setJobDetail(jobDetailFactoryBean().getObject());
        return bean;
    }
    @Bean
    SchedulerFactoryBean schedulerFactoryBean() {
        SchedulerFactoryBean bean = new SchedulerFactoryBean();
        bean.setTriggers(cronTrigger().getObject(), simpleTriggerFactoryBean().getObject());
        return bean;
    }
    @Bean
    HelloService helloService() {
        return new HelloService();
    }
}

关于这个配置说如下几点:

  • JobDetail 的配置有两种方式:MethodInvokingJobDetailFactoryBean 和 JobDetailFactoryBean 。
  • 使用 MethodInvokingJobDetailFactoryBean 可以配置目标 Bean 的名字和目标方法的名字,这种方式不支持传参。
  • 使用 JobDetailFactoryBean 可以配置 JobDetail ,任务类继承自 QuartzJobBean ,这种方式支持传参,将参数封装在 JobDataMap 中进行传递。
  • Trigger 是指触发器,Quartz 中定义了多个触发器,这里向大家展示其中两种的用法,SimpleTrigger 和 CronTrigger 。
  • SimpleTrigger 有点类似于前面说的 @Scheduled 的基本用法。
  • CronTrigger 则有点类似于 @Scheduled 中 cron 表达式的用法。

img

9. Web基础组件

@WebListener
public class MyListener extends RequestContextListener {
    @Override
    public void requestInitialized(ServletRequestEvent requestEvent) {
        System.out.println("requestInitialized");
    }

    @Override
    public void requestDestroyed(ServletRequestEvent requestEvent) {
        System.out.println("requestDestroyed");
    }
}

@WebServlet(urlPatterns = "/hello")
public class MyServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        doPost(req,resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        System.out.println("MyServlet");
    }
}

//启动类
@SpringBootApplication
@ServletComponentScan("org.javaboy.webcomponent")
public class WebcomponentApplication {

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

}

关于过滤器,一般推荐使用拦截器,但这里还是对过滤器进行一定的说明

//基础用法
@WebFilter(urlPatterns = "/*")
public class MyFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("MyFilter");
        chain.doFilter(request,response);
    }
}

//设置优先级需要用当基础组件处理
@Component
//数字越小优先级越高
@Order(101)
public class MyFilter02 implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("MyFilter02");
        chain.doFilter(request, response);
    }
}

//上述过滤器2无法指定拦截url,需要更换配置
public class MyFilter03 implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("MyFilter04");
        chain.doFilter(request, response);
    }
}
//针对03对应的配置类
@Configuration
public class FilterConfiguration {
    @Bean
    FilterRegistrationBean<MyFilter03> filter03FilterRegistrationBean03() {
        FilterRegistrationBean<MyFilter03> bean = new FilterRegistrationBean<>();
        bean.setOrder(90);
        bean.setFilter(new MyFilter03());
        bean.setUrlPatterns(Arrays.asList("/*"));
        return bean;
    }
}

10. AOP

Spring中已经对[AOP注解编程](#6. 基于注解的AOP编程)进行了讲解,这里做一定的补充

//UserService
@Service
public class UserService {
    public String getUserById(Integer id) {
        System.out.println("getUserById");
        int i = 1 / 0;
        return "user";
    }

    public void deleteUserById(Integer id) {
        System.out.println("delete id:" + id);
    }
}

//LogAspect
@Component
@Aspect
public class LogAspect {
    @Pointcut("execution(* org.javaboy.aop.service.*.*(..))")
    public void pc1() {

    }

    @Before("pc1()")
    public void before(JoinPoint jp) {
        String name = jp.getSignature().getName();
        System.out.println(name + " 方法开始执行了...");
    }

    @After("pc1()")
    public void after(JoinPoint jp) {
        String name = jp.getSignature().getName();
        System.out.println(name + " 方法执行结束了...");
    }

    @AfterReturning(value = "pc1()", returning = "s")
    public void afterReturning(JoinPoint jp, String s) {
        String name = jp.getSignature().getName();
        System.out.println(name + " 方法的返回值是 " + s);
    }

    @AfterThrowing(value = "pc1()", throwing = "e")
    public void afterThrowing(JoinPoint jp, Exception e) {
        String name = jp.getSignature().getName();
        System.out.println(name + " 方法抛出了异常 " + e.getMessage());
    }

    @Around("pc1()")
    public Object around(ProceedingJoinPoint pjp) {
        try {

            //类似于反射中的 invoke 方法
            Object proceed = pjp.proceed();
            return proceed;
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        return null;
    }
}

11. 整合Swagger

11.1 引言

相信无论是前端还是后端开发,都或多或少地被接口文档折磨过。前端经常抱怨后端给的接口文档和实际情况不一致。后端又觉得编写及维护接口文档会耗费不少精力,经常来不及更新。其实无论是前端调用后端,还是后端调用前端,都期望有一个好的接口文档。但是这个接口文档对于程序员来说,就跟注释一样,经常还会抱怨别人写的代码没有写注释,然而自己写起代码来,最讨厌的也是写注释。所以仅仅只听过强制了来规范大家是不够的,随着时间推移,版本迭代,接口文档往往很容易就跟不上代码了

11.2 什么是Swagger

发现了痛点就要去找解决方案。解决方案用的人多了,就成了标准的规范,这就是 Swagger 的由来。通过这套规范,你只需要按照它的规范去定义接口及接口相关信息。再通过 Swagger 衍生出来的一系列项目和工具,就可以做到生成各种格式的接口文档,生成多做语言的客户端和服务端的代码,以及在线接口调试页面等等。这样,如果按照新的开发木事,在开发新版本或者迭代版本的时候,只需要更新 Swagger 描述文件,就可以自动生成接口文档和客户端代码,做到调用端代码、服务端代码以及接口文档的一致性。 但即便如此,对于许多开发来说,编写这个 yml 或 json 格式的描述文件,本身也是有一定负担的工作,特别是在后面持续迭代开发的时候,往往会忽略更新这个描述文件,直接更改代码。久而久之,这个描述文件也和实际项目渐行渐远,基于该描述文件生成的接口文档也失去了参考意义。所以作为 Java 界服务端的大一统框架 Spring,迅速将 Swagger 规范纳入自身的标准,建立了 Spring-swagger 项目,后面改成了现在的 Springfox。通过在项目中引入 Springfox,可以扫描相关的代码,生成该描述文件,进而生成与代码一致的接口文档和客户端代码。这种通过代码生成接口文档的形式,在后面需求持续迭代的项目中,显得尤为重要和高效

image-20210519160517871

总结:Swagger 就是一个用来定义接口标准,接口规范,同时能根据你的代码自动生成接口说明文档的一个工具

11.3 官方提供的工具

Swagger Codegen:通过Codegen 可以将描述文件生成 html 格式和 cwiki 形式的接口文档,同时也能生成多种语言的服务端和客户端代码。支持通过 jar 包、docker、node 等方式在本地化执行生成。也可以在后面的 Swagger Editor 中在线生成。

Swagger UI:提供了一个可视化的 UI 页面展示描述文件。接口的调用方、测试、项目经理等都可以在该页面中对相关接口进行查阅和做一些简单的接口请求。该项目支持在线导入描述文件和本地部署 UI 项目。

Swagger Editor:类似于 Markdown 编辑器的编辑 Swagger 描述文件的编辑器,该编辑器支持实时预览描述文件的更新效果,也提供了在线编辑器和本地部署器俩种方式。

Swagger Inspector:感觉和 Postman 差不多,是一个可以对接口进行测试的在线版的 postman。比如在 Swagger UI 里面做接口请求,会返回更多的信息,也会保存你请求的实际请求参数等数据。

Swagger Hub:集成了上面所有项目的各个功能,你可以以项目和版本为单位,将你的描述文件上传到 Swagger Hub 中。在 Swagger Hub 中可以完成上面项目的所有工作,需要注册账号,分免费版和收费版。

Springfox Swagger:Spring 基于 Swagger 规范,可以将基于 SpringMVC 和 Spring Boot 项目的项目代码,自动生成 JSON 格式的描述文件。本身不是属于 Swagger 官网提供的,在这里列出来做个说明,方便后面作一个使用的展开。

11.4 构建 Swagger 与 Spring Boot 环境

11.4.1 引入依赖

<!-- https://mvnrepository.com/artifact/io.springfox/springfox-swagger2 -->
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>2.9.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.springfox/springfox-swagger-ui -->
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    <version>2.9.2</version>
</dependency>

11.4.2 编写 Swagger 配置类

这个配置类基本都是不变的

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

@Configuration
@EnableSwagger2  
public class SwaggerConfig {

    @Bean
    public Docket createRestApi(){
        return new Docket(DocumentationType.SWAGGER_2)
                .pathMapping("/")
                .select()
                // 扫描哪个接口的包
                .apis(RequestHandlerSelectors.basePackage("com.example.controller"))
                .paths(PathSelectors.any())
                .build().apiInfo(new ApiInfoBuilder()
                        .title("标题: SpringBoot 整合 Swagger 使用")
                        .description("详细信息: SpringBoot 整合 Swagger,详细信息......")
                        // 版本信息
                        .version("1.1")
                        // 开发文档的联系人
                    .contact(new Contact("lucifer", "https://lucifer2u.github.io","xiaoweliangx@gmail.com"))
                        .license("This Baidu License")
                        .licenseUrl("http://www.baidu.com")
                        .build());
    }
}

11.4.3 启动 SpringBoot 项目

image-20210519162040084

11.4.4 访问 Swagger 的 UI 界面

访问 Swagger 提供的 UI 界面:http://localhost:8080/swagger-ui.html

image-20210519162204984

11.5 使用 Swagger 构建

11.5.1 开发 Controller 接口

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/user")
public class HelloController {

    @GetMapping("/findAll")
    public Map<String,Object> findAll(){
        Map<String, Object> map = new HashMap<>();
        map.put("success", "查询所有数据成功");
        map.put("status", true);
        return map;
    }
}

11.5.2 重启项目访问接口界面

image-20210519162452762

11.6 Swagger 注解

11.6.1 @Api

作用:用来指定接口的描述文字
修饰范围:用在类上

@RequestMapping("/user")
@Api(tags = "用户服务相关接口描叙")
public class HelloController {

		....
}

11.6.2 @ApiOperation

作用:用来对接口中具体方法做描叙
修饰范围:用在方法上

@GetMapping("/findAll")
@ApiOperation(value = "查询所有用户接口",
        notes = "<span style='color:red;'>描叙:</span>&nbsp;&nbsp;用来查询所有用户信息的接口")
public Map<String,Object> findAll(){
    Map<String, Object> map = new HashMap<>();
    map.put("success", "查询所有数据成功");
    map.put("status", true);
    return map;
}

11.6.3 @ApiImplicitParams

作用:用来接口中参数进行说明
修饰范围:用在方法上

普通参数使用
@PostMapping("save")
@ApiOperation(value = "保存用户信息接口",
        notes = "<span style='color:red;'>描叙:</span>&nbsp;&nbsp;用来保存用户信息的接口")
@ApiImplicitParams({
        @ApiImplicitParam(name = "id", value = "用户 id", dataType = "String", defaultValue = "21"),
        @ApiImplicitParam(name = "name", value = "用户姓名", dataType = "String", defaultValue = "lucifer")
})
public Map<String, Object> save(String id, String name) {
    System.out.println("id = " + id);
    System.out.println("name = " + name);
    Map<String, Object> map = new HashMap<>();
    map.put("id", id);
    map.put("name", name);
    return map;
}

image-20210519163323183

RestFul 风格使用

如果使用的是 RestFul 风格进行传参,必须再添加一个 paramType=”path”

@PostMapping("save/{id}/{name}")
@ApiOperation(value = "保存用户信息接口",
        notes = "<span style='color:red;'>描叙:</span>&nbsp;&nbsp;用来保存用户信息的接口")
@ApiImplicitParams({
        @ApiImplicitParam(name = "id", value = "用户 id", dataType = "String", defaultValue = "21", paramType = "path"),
        @ApiImplicitParam(name = "name", value = "用户姓名", dataType = "String", defaultValue = "lucifer", paramType = "path")
})

public Map<String, Object> save(@PathVariable("id") String id,@PathVariable("name") String name) {
    System.out.println("id = " + id);
    System.out.println("name = " + name);
    Map<String, Object> map = new HashMap<>();
    map.put("id", id);
    map.put("name", name);
    return map;
}

image-20210519163641192

JSON 格式使用

如果是 RequestBody 的方式,需要定义一个对象进行接收

定义一个 User 对象

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
    private String id;
    private String name;
}

编写 Controller

@PostMapping("save2")
public Map<String, Object> save2(@RequestBody User user) {
    System.out.println("id = " + user.getId());
    System.out.println("name = " + user.getName());
    Map<String, Object> map = new HashMap<>();
    map.put("id", user.getId());
    map.put("name", user.getName());
    return map;
}

image-20210519163919745

image-20210519164215434

11.6.4 @ApiResponses

作用:用在请求的方法上,表示一组响应
修饰范围:用在方法上

@PostMapping("save2")
@ApiResponses({
        @ApiResponse(code = 404, message = "请求路径不对"),
        @ApiResponse(code = 400, message = "程序不对")
})
public Map<String, Object> save2(@RequestBody User user) {
    System.out.println("id = " + user.getId());
    System.out.println("name = " + user.getName());
    Map<String, Object> map = new HashMap<>();
    map.put("id", user.getId());
    map.put("name", user.getName());
    return map;
}

img

11.6.5 @ApiModel

如果参数是一个对象(例如上文的更新接口),对于参数的描述也可以放在实体类中。例如下面一段代码:

@ApiModel(value = "用户实体类", description= "定义了用户所有属性")
public class User {
    @ApiModelProperty(value = "用户id")
    private Integer id;
    @ApiModelProperty(value = "用户名")
    private String username;
    @ApiModelProperty(value = "用户地址")
    private String address;
    //getter/setter
}

11.7 在Security中的配置

如果我们的Spring Boot项目中集成了Spring Security,那么如果不做额外配置,Swagger2文档可能会被拦截,此时只需要在Spring Security的配置类中重写configure方法,添加如下过滤即可:

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring()
            .antMatchers("/swagger-ui.html")
            .antMatchers("/v2/**")
            .antMatchers("/swagger-resources/**");
}

11.8 Swagger 3.0

在 3.0 版本中,我们不需要这么麻烦了,一个 starter 就可以搞定:

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-boot-starter</artifactId>
    <version>3.0.0</version>
</dependency>

和 Spring Boot 中的其他 starter 一样,springfox-boot-starter 依赖可以实现零配置以及自动配置支持。也就是说,如果你没有其他特殊需求,加一个这个依赖就行了,接口文档就自动生成了。

接口地址

3.0 中的接口地址也和之前有所不同,以前在 2.9.2 中我们主要访问两个地址:

现在在 3.0 中,这两个地址也发生了变化:

特别是文档页面地址,如果用了 3.0,而去访问之前的页面,会报 404

注解

旧的注解还可以继续使用,不过在 3.0 中还提供了一些其他注解。

例如我们可以使用 @EnableOpenApi 代替以前旧版本中的 @EnableSwagger2。

话是这么说,不过松哥在实际体验中,感觉 @EnableOpenApi 注解的功能不明显,加不加都行。翻了下源码,@EnableOpenApi 注解主要功能是为了导入 OpenApiDocumentationConfiguration 配置类,如下:

@Retention(value = java.lang.annotation.RetentionPolicy.RUNTIME)
@Target(value = {java.lang.annotation.ElementType.TYPE})
@Documented
@Import(OpenApiDocumentationConfiguration.class)
public @interface EnableOpenApi {
}

然后我又看了下自动化配置类 OpenApiAutoConfiguration,如下:

@Configuration
@EnableConfigurationProperties(SpringfoxConfigurationProperties.class)
@ConditionalOnProperty(value = "springfox.documentation.enabled", havingValue = "true", matchIfMissing = true)
@Import({
    OpenApiDocumentationConfiguration.class,
    SpringDataRestConfiguration.class,
    BeanValidatorPluginsConfiguration.class,
    Swagger2DocumentationConfiguration.class,
    SwaggerUiWebFluxConfiguration.class,
    SwaggerUiWebMvcConfiguration.class
})
@AutoConfigureAfter({ WebMvcAutoConfiguration.class, JacksonAutoConfiguration.class,
    HttpMessageConvertersAutoConfiguration.class, RepositoryRestMvcAutoConfiguration.class })
public class OpenApiAutoConfiguration {

}

可以看到,自动化配置类里边也导入了 OpenApiDocumentationConfiguration。

所以在正常情况下,实际上不需要添加 @EnableOpenApi 注解。

根据 OpenApiAutoConfiguration 上的 @ConditionalOnProperty 条件注解中的定义,我们发现,如果在 application.properties 中设置 springfox.documentation.enabled=false,即关闭了 swagger 功能,此时自动化配置类就不执行了,这个时候可以通过 @EnableOpenApi 注解导入 OpenApiDocumentationConfiguration 配置类。技术上来说逻辑是这样,不过应用中暂未发现这样的需求(即在 application.properties 中关闭 swagger,再通过 @EnableOpenApi 注解开启)。

另外,以前我们用的 @ApiResponses/@ApiResponse 注解,在 3.0 中名字没变,但是所在的包变了,小伙伴们使用时注意导包问题哦。

另外,我们之前用的 @ApiOperation 注解在 3.0 中可以使用 @Operation 代替。

另外还有一些新注解如 @Parameter、@Parameters、@Schema 等,感觉不太好用,不如旧的用的舒服,这些新注解小伙伴们可以自行尝试下。

12. 数据校验

依赖引入

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

配置文件 ValidationMessages.properties

user.name.size=name xxx
user.address.notnull=address xxx
user.age.min=age xxx
user.age.max=age xxx
user.email.notnull=email xxx
user.email.pattern=email xxx

分组校验,首先需要定义校验组,所谓的校验组,其实就是空接口

public interface ValidationGroup1 {
}
public interface ValidationGroup2 {
}

实体类

public class User {
    private Long id;
    @Size(min = 5,max = 8,message = "{user.name.size}",groups = ValidationGroup1.class)
    private String name;
    @NotNull(message = "{user.address.notnull}",groups = ValidationGroup2.class)
    private String address;
    @DecimalMin(value = "1",message = "{user.age.min}",groups = {ValidationGroup1.class,ValidationGroup2.class})
    @DecimalMax(value = "200",message = "{user.age.max}")
    private Integer age;
    @NotNull(message = "{user.email.notnull}")
    @Email(message = "{user.email.pattern}")
    private String email;
}

校验注解,主要有如下几种:

  • @Null 被注解的元素必须为 null
  • @NotNull 被注解的元素必须不为 null
  • @AssertTrue 被注解的元素必须为 true
  • @AssertFalse 被注解的元素必须为 false
  • @Min(value) 被注解的元素必须是一个数字,其值必须大于等于指定的最小值
  • @Max(value) 被注解的元素必须是一个数字,其值必须小于等于指定的最大值
  • @DecimalMin(value) 被注解的元素必须是一个数字,其值必须大于等于指定的最小值
  • @DecimalMax(value) 被注解的元素必须是一个数字,其值必须小于等于指定的最大值
  • @Size(max=, min=) 被注解的元素的大小必须在指定的范围内
  • @Digits (integer, fraction) 被注解的元素必须是一个数字,其值必须在可接受的范围内
  • @Past 被注解的元素必须是一个过去的日期
  • @Future 被注解的元素必须是一个将来的日期
  • @Pattern(regex=,flag=) 被注解的元素必须符合指定的正则表达式
  • @NotBlank(message =) 验证字符串非 null,且长度必须大于0
  • @Email 被注解的元素必须是电子邮箱地址
  • @Length(min=,max=) 被注解的字符串的大小必须在指定的范围内
  • @NotEmpty 被注解的字符串的必须非空
  • @Range(min=,max=,message=) 被注解的元素必须在合适的范围内

Controller

@RestController
public class UserController {
    @PostMapping("/user")
    public void addUser(@Validated(ValidationGroup1.class) User user, BindingResult result) {
        if (result != null && result.hasErrors()) {
            List<ObjectError> allErrors = result.getAllErrors();
            for (ObjectError error : allErrors) {
                System.out.println(error.getObjectName() + error.getDefaultMessage());
            }
        }

    }
}

SpringBoot整合持久层

1. 整合JDBCTemplate

在Java领域,数据持久化有几个常见的方案,有Spring自带的JdbcTemplate、有MyBatis,还有JPA,在这些方案中,最简单的就是Spring自带的JdbcTemplate了,这个东西虽然没有MyBatis那么方便,但是比起最开始的Jdbc已经强了很多了,它没有MyBatis功能那么强大,当然也意味着它的使用比较简单,事实上,JdbcTemplate算是最简单的数据持久化方案

1.1 基本配置

JdbcTemplate基本用法实际上很简单,开发者在创建一个SpringBoot项目时,除了选择基本的Web依赖,再记得选上Jdbc依赖,以及数据库驱动依赖即可,如下:

img

项目创建成功之后,记得添加Druid数据库连接池依赖(注意这里可以添加专门为Spring Boot打造的druid-spring-boot-starter,而不是我们一般在SSM中添加的Druid),所有添加的依赖如下:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.10</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.27</version>
    <scope>runtime</scope>
</dependency>

项目创建完后,接下来只需要在application.properties中提供数据的基本配置即可,如下:

spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mysql:///test01?useUnicode=true&characterEncoding=UTF-8

如此之后,所有的配置就算完成了,接下来就可以直接使用JdbcTemplate,其实这就是SpringBoot的自动化配置带来的好处

1.2 基本用法

首先我们来创建一个User Bean,如下:

public class User {
    private Long id;
    private String username;
    private String address;
    //省略getter/setter
}

然后来创建一个UserService类,在UserService类中注入JdbcTemplate,如下:

@Service
public class UserService {
    @Autowired
    JdbcTemplate jdbcTemplate;
}

好了,如此之后,准备工作就算完成了。

JdbcTemplate中,除了查询有几个API之外,增删改统一都使用update来操作,自己来传入SQL即可。例如添加数据,方法如下:

public int addUser(User user) {
    return jdbcTemplate.update("insert into user (username,address) values (?,?);", user.getUsername(), user.getAddress());
}

update方法的返回值就是SQL执行受影响的行数。

这里只需要传入SQL即可,如果你的需求比较复杂,例如在数据插入的过程中希望实现主键回填,那么可以使用PreparedStatementCreator,如下:

public int addUser2(User user) {
  
    KeyHolder keyHolder = new GeneratedKeyHolder();
    int update = jdbcTemplate.update(new PreparedStatementCreator() {
      
        @Override
        public PreparedStatement createPreparedStatement(Connection connection) throws SQLException {
            PreparedStatement ps = connection.prepareStatement("insert into user (username,address) values (?,?);", Statement.RETURN_GENERATED_KEYS);
            ps.setString(1, user.getUsername());
            ps.setString(2, user.getAddress());
            return ps;
        }
    }, keyHolder);
    user.setId(keyHolder.getKey().longValue());
    System.out.println(user);
    return update;
}

实际上这里就相当于完全使用了JDBC中的解决方案了,首先在构建PreparedStatement时传入Statement.RETURN_GENERATED_KEYS,然后传入KeyHolder,最终从KeyHolder中获取刚刚插入数据的id保存到user对象的id属性中去。

你能想到的JDBC的用法,在这里都能实现,Spring提供的JdbcTemplate虽然不如MyBatis,但是比起Jdbc还是要方便很多的。

删除也是使用update API,传入你的SQL即可:

public int deleteUserById(Long id) {
    return jdbcTemplate.update("delete from user where id=?", id);
}

当然你也可以使用PreparedStatementCreator

public int updateUserById(User user) {
    return jdbcTemplate.update("update user set username=?,address=? where id=?", user.getUsername(), user.getAddress(),user.getId());
}

当然你也可以使用PreparedStatementCreator

查询的话,稍微有点变化,这里主要向大伙介绍query方法,例如查询所有用户:

public List<User> getAllUsers() {
    return jdbcTemplate.query("select * from user", new RowMapper<User>() {
        @Override
        public User mapRow(ResultSet resultSet, int i) throws SQLException {
            String username = resultSet.getString("username");
            String address = resultSet.getString("address");
            long id = resultSet.getLong("id");
            User user = new User();
            user.setAddress(address);
            user.setUsername(username);
            user.setId(id);
            return user;
        }
    });
}

查询的时候需要提供一个RowMapper,就是需要自己手动映射,将数据库中的字段和对象的属性一一对应起来,这样看起来有点麻烦,实际上,如果数据库中的字段和对象属性的名字一模一样的话,有另外一个简单的方案,如下:

public List<User> getAllUsers2() {
    return jdbcTemplate.query("select * from user", new BeanPropertyRowMapper<>(User.class));
}

至于查询时候传参也是使用占位符,这个和前文的一致,这里不再赘述。

其他

除了这些基本用法之外,JdbcTemplate也支持其他用法,例如调用存储过程等,这些都比较容易,而且和Jdbc本身都比较相似,这里也就不做介绍了,有兴趣可以留言讨论。

1.3 原理分析

那么在SpringBoot中,配置完数据库基本信息之后,就有了一个JdbcTemplate了,这个东西是从哪里来的呢?源码在org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration类中,该类源码如下:

@Configuration
@ConditionalOnClass({ DataSource.class, JdbcTemplate.class })
@ConditionalOnSingleCandidate(DataSource.class)
@AutoConfigureAfter(DataSourceAutoConfiguration.class)
@EnableConfigurationProperties(JdbcProperties.class)
public class JdbcTemplateAutoConfiguration {

	@Configuration
	static class JdbcTemplateConfiguration {

		private final DataSource dataSource;

		private final JdbcProperties properties;

		JdbcTemplateConfiguration(DataSource dataSource, JdbcProperties properties) {
			this.dataSource = dataSource;
			this.properties = properties;
		}

		@Bean
		@Primary
		@ConditionalOnMissingBean(JdbcOperations.class)
		public JdbcTemplate jdbcTemplate() {
			JdbcTemplate jdbcTemplate = new JdbcTemplate(this.dataSource);
			JdbcProperties.Template template = this.properties.getTemplate();
			jdbcTemplate.setFetchSize(template.getFetchSize());
			jdbcTemplate.setMaxRows(template.getMaxRows());
			if (template.getQueryTimeout() != null) {
				jdbcTemplate
						.setQueryTimeout((int) template.getQueryTimeout().getSeconds());
			}
			return jdbcTemplate;
		}

	}

	@Configuration
	@Import(JdbcTemplateConfiguration.class)
	static class NamedParameterJdbcTemplateConfiguration {

		@Bean
		@Primary
		@ConditionalOnSingleCandidate(JdbcTemplate.class)
		@ConditionalOnMissingBean(NamedParameterJdbcOperations.class)
		public NamedParameterJdbcTemplate namedParameterJdbcTemplate(
				JdbcTemplate jdbcTemplate) {
			return new NamedParameterJdbcTemplate(jdbcTemplate);
		}

	}

}

从这个类中,大致可以看出,当当前类路径下存在DataSource和JdbcTemplate时,该类就会被自动配置,jdbcTemplate方法则表示,如果开发者没有自己提供一个JdbcOperations的实例的话,系统就自动配置一个JdbcTemplate Bean(JdbcTemplate是JdbcOperations接口的一个实现)

1.4 多数据源配置

多数据源配置也算是一个常见的开发需求,Spring和SpringBoot中,对此都有相应的解决方案,不过一般来说,如果有多数据源的需求,我还是建议首选分布式数据库中间件MyCat去解决相关问题,之前有小伙伴在我的知识星球上提问,他的数据根据条件的不同,可能保存在四十多个不同的数据库中,怎么办?这种场景下使用多数据源其实就有些费事了,我给的建议是使用MyCat,然后分表策略使用sharding-by-intfile。当然如果一些简单的需求,还是可以使用多数据源的,Spring Boot中,JdbcTemplate、MyBatis以及Jpa都可以配置多数据源,本文就先和大伙聊一聊JdbcTemplate中多数据源的配置

1.4.1 创建工程

首先是创建工程,和前文一样,创建工程时,也是选择Web、Jdbc以及MySQL驱动,如下图:

img

创建成功之后,一定接下来手动添加Druid依赖,由于这里一会需要开发者自己配置DataSoruce,所以这里必须要使用druid-spring-boot-starter依赖,而不是传统的那个druid依赖,因为druid-spring-boot-starter依赖提供了DruidDataSourceBuilder类,这个可以用来构建一个DataSource实例,而传统的Druid则没有该类。完整的依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.28</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.10</version>
</dependency>

1.4.2 配置数据源

接下来,在application.properties中配置数据源,不同于上文,这里的数据源需要配置两个,如下:

spring.datasource.one.url=jdbc:mysql:///test01?useUnicode=true&characterEncoding=utf-8
spring.datasource.one.username=root
spring.datasource.one.password=root
spring.datasource.one.type=com.alibaba.druid.pool.DruidDataSource

spring.datasource.two.url=jdbc:mysql:///test02?useUnicode=true&characterEncoding=utf-8
spring.datasource.two.username=root
spring.datasource.two.password=root
spring.datasource.two.type=com.alibaba.druid.pool.DruidDataSource

这里通过one和two对数据源进行了区分,但是加了one和two之后,这里的配置就没法被SpringBoot自动加载了(因为前面的key变了),需要我们自己去加载DataSource了,此时,需要自己配置一个DataSourceConfig,用来提供两个DataSource Bean,如下:

@Configuration
public class DataSourceConfig {
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.one")
    DataSource dsOne() {
        return DruidDataSourceBuilder.create().build();
    }
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.two")
    DataSource dsTwo() {
        return DruidDataSourceBuilder.create().build();
    }
}

这里提供了两个Bean,其中@ConfigurationProperties是Spring Boot提供的类型安全的属性绑定,以第一个Bean为例,@ConfigurationProperties(prefix = "spring.datasource.one")表示使用spring.datasource.one前缀的数据库配置去创建一个DataSource,这样配置之后,我们就有了两个不同的DataSource,接下来再用这两个不同的DataSource去创建两个不同的JdbcTemplate

1.4.3 配置JdbcTemplate实例

创建JdbcTemplateConfig类,用来提供两个不同的JdbcTemplate实例,如下:

@Configuration
public class JdbcTemplateConfig {

    @Bean
    JdbcTemplate jdbcTemplateOne(@Qualifier("dsOne") DataSource dsOne) {
        return new JdbcTemplate(dsOne);
    }

    @Bean
    JdbcTemplate jdbcTemplateTwo(@Qualifier("dsTwo") DataSource dsTwo) {
        return new JdbcTemplate(dsTwo);
    }
}

每一个JdbcTemplate的创建都需要一个DataSource,由于Spring容器中现在存在两个DataSource,默认使用类型查找,会报错,因此加上@Qualifier注解,表示按照名称查找。这里创建了两个JdbcTemplate实例,分别对应了两个DataSource,接下来直接去使用这个JdbcTemplate就可以了

1.4.4 测试使用

关于JdbcTemplate的详细用法大伙可以参考我的上篇文章,这里我主要演示数据源的差异,在Controller中注入两个不同的JdbcTemplate,这两个JdbcTemplate分别对应了不同的数据源,如下:

@RestController
public class HelloController {
    @Autowired
    @Qualifier("jdbcTemplateOne")
    JdbcTemplate jdbcTemplateOne;
    @Resource(name = "jdbcTemplateTwo")
    JdbcTemplate jdbcTemplateTwo;

    @GetMapping("/user")
    public List<User> getAllUser() {
        List<User> list = jdbcTemplateOne.query("select * from t_user", new BeanPropertyRowMapper<>(User.class));
        return list;
    }
    @GetMapping("/user2")
    public List<User> getAllUser2() {
        List<User> list = jdbcTemplateTwo.query("select * from t_user", new BeanPropertyRowMapper<>(User.class));
        return list;
    }
}

和DataSource一样,Spring容器中的JdbcTemplate也是有两个,因此不能通过byType的方式注入进来,这里给大伙提供了两种注入思路,一种是使用@Resource注解,直接通过byName的方式注入进来,另外一种就是@Autowired注解加上@Qualifier注解,两者联合起来,实际上也是byName。将JdbcTemplate注入进来之后,jdbcTemplateOne和jdbcTemplateTwo此时就代表操作不同的数据源,使用不同的JdbcTemplate操作不同的数据源,实现了多数据源配置。

2. 整合MyBatis

JdbcTemplate虽然简单,但是用的并不多,因为它没有MyBatis方便,在Spring+SpringMVC中整合MyBatis步骤还是有点复杂的,要配置多个Bean,Spring Boot中对此做了进一步的简化,使MyBatis基本上可以做到开箱即用,本文就来看看在Spring Boot中MyBatis要如何使用

2.1 工程创建

首先创建一个基本的Spring Boot工程,添加Web依赖,MyBatis依赖以及MySQL驱动依赖,如下:

img

创建成功后,添加Druid依赖,并且锁定MySQL驱动版本,完整的依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.0.0</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.10</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.28</version>
    <scope>runtime</scope>
</dependency>

如此,工程就算是创建成功了。读者注意,MyBatis和Druid依赖的命名和其他库的命名不太一样,是属于xxx-spring-boot-stater模式的,这表示该starter是由第三方提供的。

2.2 基本用法

MyBatis的使用和JdbcTemplate基本一致,首先也是在application.properties中配置数据库的基本信息:

spring.datasource.url=jdbc:mysql:///test01?useUnicode=true&characterEncoding=utf-8
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource

配置完成后,MyBatis就可以创建Mapper来使用了,例如我这里直接创建一个UserMapper2,如下:

public interface UserMapper2 {
    @Select("select * from user")
    List<User> getAllUsers();

    @Results({
            @Result(property = "id", column = "id"),
            @Result(property = "username", column = "u"),
            @Result(property = "address", column = "a")
    })
    @Select("select username as u,address as a,id as id from user where id=#{id}")
    User getUserById(Long id);

    @Select("select * from user where username like concat('%',#{name},'%')")
    List<User> getUsersByName(String name);

    @Insert({"insert into user(username,address) values(#{username},#{address})"})
    @SelectKey(statement = "select last_insert_id()", keyProperty = "id", before = false, resultType = Integer.class)
    Integer addUser(User user);

    @Update("update user set username=#{username},address=#{address} where id=#{id}")
    Integer updateUserById(User user);

    @Delete("delete from user where id=#{id}")
    Integer deleteUserById(Integer id);
}

这里是通过全注解的方式来写SQL,不写XML文件,@Select、@Insert、@Update以及@Delete四个注解分别对应XML中的select、insert、update以及delete标签,@Results注解类似于XML中的ResultMap映射文件(getUserById方法给查询结果的字段取别名主要是向小伙伴们演示下@Results注解的用法),另外使用@SelectKey注解可以实现主键回填的功能,即当数据插入成功后,插入成功的数据id会赋值到user对象的id属性上。

UserMapper2创建好之后,还要配置mapper扫描,有两种方式,一种是直接在UserMapper2上面添加@Mapper注解,这种方式有一个弊端就是所有的Mapper都要手动添加,要是落下一个就会报错,还有一个一劳永逸的办法就是直接在启动类上添加Mapper扫描,如下:

@SpringBootApplication
@MapperScan(basePackages = "org.sang.mybatis.mapper")
public class MybatisApplication {
    public static void main(String[] args) {
        SpringApplication.run(MybatisApplication.class, args);
    }
}

好了,做完这些工作就可以去测试Mapper的使用了。

2.3 mapper映射

当然,开发者也可以在XML中写SQL,例如创建一个UserMapper,如下:

public interface UserMapper {
    List<User> getAllUser();

    Integer addUser(User user);

    Integer updateUserById(User user);

    Integer deleteUserById(Integer id);
}

然后创建UserMapper.xml文件,如下:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.sang.mybatis.mapper.UserMapper">
    <select id="getAllUser" resultType="org.sang.mybatis.model.User">
        select * from t_user;
    </select>
    <insert id="addUser" parameterType="org.sang.mybatis.model.User">
        insert into user (username,address) values (#{username},#{address});
    </insert>
    <update id="updateUserById" parameterType="org.sang.mybatis.model.User">
        update user set username=#{username},address=#{address} where id=#{id}
    </update>
    <delete id="deleteUserById">
        delete from user where id=#{id}
    </delete>
</mapper>

    <!--或者定义resultMap去映射-->
<resultMap id="UserMap" type="org.javaboy.mybatis.model.User">
  <id property="id" column="id"/>
  <result property="username" column="username"/>
  <result property="address" column="address1"/>
</resultMap>

<select id="getUserById" resultMap="UserMap">
  select * from user where id=#{id};
</select>

<select id="getAllUsers" resultMap="UserMap">
  select * from user ;
</select>

将接口中方法对应的SQL直接写在XML文件中

那么这个UserMapper.xml到底放在哪里呢?有两个位置可以放,第一个是直接放在UserMapper所在的包下面:

img

放在这里的UserMapper.xml会被自动扫描到,但是有另外一个Maven带来的问题,就是java目录下的xml资源在项目打包时会被忽略掉,所以,如果UserMapper.xml放在包下,需要在pom.xml文件中再添加如下配置,避免打包时java目录下的XML文件被自动忽略掉:

<build>
    <resources>
        <resource>
            <directory>src/main/java</directory>
            <includes>
                <include>**/*.xml</include>
            </includes>
        </resource>
        <resource>
            <directory>src/main/resources</directory>
        </resource>
    </resources>
</build>

当然,UserMapper.xml也可以直接放在resources目录下,这样就不用担心打包时被忽略了,但是放在resources目录下,又不能自动被扫描到,需要添加额外配置。例如我在resources目录下创建mapper目录用来放mapper文件,如下:

img

此时在application.properties中告诉mybatis去哪里扫描mapper:

mybatis.mapper-locations=classpath:mapper/*.xml

如此配置之后,mapper就可以正常使用了。注意第二种方式不需要在pom.xml文件中配置文件过滤。

2.4 原理分析

在SSM整合中,开发者需要自己提供两个Bean,一个SqlSessionFactoryBean,还有一个是MapperScannerConfigurer,在Spring Boot中,这两个东西虽然不用开发者自己提供了,但是并不意味着这两个Bean不需要了,在org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration类中,我们可以看到Spring Boot提供了这两个Bean,部分源码如下:

@org.springframework.context.annotation.Configuration
@ConditionalOnClass({ SqlSessionFactory.class, SqlSessionFactoryBean.class })
@ConditionalOnSingleCandidate(DataSource.class)
@EnableConfigurationProperties(MybatisProperties.class)
@AutoConfigureAfter(DataSourceAutoConfiguration.class)
public class MybatisAutoConfiguration implements InitializingBean {

  @Bean
  @ConditionalOnMissingBean
  public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
    SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
    factory.setDataSource(dataSource);
    return factory.getObject();
  }
  @Bean
  @ConditionalOnMissingBean
  public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
    ExecutorType executorType = this.properties.getExecutorType();
    if (executorType != null) {
      return new SqlSessionTemplate(sqlSessionFactory, executorType);
    } else {
      return new SqlSessionTemplate(sqlSessionFactory);
    }
  }
  @org.springframework.context.annotation.Configuration
  @Import({ AutoConfiguredMapperScannerRegistrar.class })
  @ConditionalOnMissingBean(MapperFactoryBean.class)
  public static class MapperScannerRegistrarNotFoundConfiguration implements InitializingBean {

    @Override
    public void afterPropertiesSet() {
      logger.debug("No {} found.", MapperFactoryBean.class.getName());
    }
  }
}

从类上的注解可以看出,当前类路径下存在SqlSessionFactory、 SqlSessionFactoryBean以及DataSource时,这里的配置才会生效,SqlSessionFactory和SqlTemplate都被提供了。

2.5 多数据源

其实关于多数据源,我的态度还是和之前一样,复杂的就直接上分布式数据库中间件,简单的再考虑多数据源。这是项目中的建议,技术上的话,当然还是各种技术都要掌握的。

2.5.1 工程创建

首先需要创建MyBatis项目,项目创建和前文的一样,添加MyBatis、MySQL以及Web依赖:

img

项目创建完成后,添加Druid依赖,和JdbcTemplate一样,这里添加Druid依赖也必须是专为Spring boot打造的Druid,不能使用传统的Druid。完整的依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.0.0</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.10</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.28</version>
    <scope>runtime</scope>
</dependency>

2.5.2 多数据源配置

接下来配置多数据源,这里基本上还是和JdbcTemplate多数据源的配置方式一致,首先在application.properties中配置数据库基本信息,然后提供两个DataSource即可,这里我再把代码贴出来,里边的道理条条框框的,大伙可以参考前面的文章,这里不再赘述。

application.properties中的配置:

spring.datasource.one.url=jdbc:mysql:///test01?useUnicode=true&characterEncoding=utf-8
spring.datasource.one.username=root
spring.datasource.one.password=root
spring.datasource.one.type=com.alibaba.druid.pool.DruidDataSource

spring.datasource.two.url=jdbc:mysql:///test02?useUnicode=true&characterEncoding=utf-8
spring.datasource.two.username=root
spring.datasource.two.password=root
spring.datasource.two.type=com.alibaba.druid.pool.DruidDataSource

然后再提供两个DataSource,如下:

@Configuration
public class DataSourceConfig {
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.one")
    DataSource dsOne() {
        return DruidDataSourceBuilder.create().build();
    }
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.two")
    DataSource dsTwo() {
        return DruidDataSourceBuilder.create().build();
    }
}

2.5.3 MyBatis配置

接下来则是MyBatis的配置,不同于JdbcTemplate,MyBatis的配置要稍微麻烦一些,因为要提供两个Bean,因此这里两个数据源我将在两个类中分开来配置,首先来看第一个数据源的配置:

@Configuration
@MapperScan(basePackages = "org.sang.mybatis.mapper1",sqlSessionFactoryRef = "sqlSessionFactory1",sqlSessionTemplateRef = "sqlSessionTemplate1")
public class MyBatisConfigOne {
    @Resource(name = "dsOne")
    DataSource dsOne;

    @Bean
    SqlSessionFactory sqlSessionFactory1() {
        SqlSessionFactory sessionFactory = null;
        try {
            SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
            bean.setDataSource(dsOne);
            sessionFactory = bean.getObject();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return sessionFactory;
    }
    @Bean
    SqlSessionTemplate sqlSessionTemplate1() {
        return new SqlSessionTemplate(sqlSessionFactory1());
    }
}

创建MyBatisConfigOne类,首先指明该类是一个配置类,配置类中要扫描的包是org.sang.mybatis.mapper1,即该包下的Mapper接口将操作dsOne中的数据,对应的SqlSessionFactory和SqlSessionTemplate分别是sqlSessionFactory1和sqlSessionTemplate1,在MyBatisConfigOne内部,分别提供SqlSessionFactory和SqlSessionTemplate即可,SqlSessionFactory根据dsOne创建,然后再根据创建好的SqlSessionFactory创建一个SqlSessionTemplate。

这里配置完成后,依据这个配置,再来配置第二个数据源即可:

@Configuration
@MapperScan(basePackages = "org.sang.mybatis.mapper2",sqlSessionFactoryRef = "sqlSessionFactory2",sqlSessionTemplateRef = "sqlSessionTemplate2")
public class MyBatisConfigTwo {
    @Resource(name = "dsTwo")
    DataSource dsTwo;

    @Bean
    SqlSessionFactory sqlSessionFactory2() {
        SqlSessionFactory sessionFactory = null;
        try {
            SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
            bean.setDataSource(dsTwo);
            sessionFactory = bean.getObject();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return sessionFactory;
    }
    @Bean
    SqlSessionTemplate sqlSessionTemplate2() {
        return new SqlSessionTemplate(sqlSessionFactory2());
    }
}

好了,这样MyBatis多数据源基本上就配置好了,接下来只需要在org.sang.mybatis.mapper1和org.sang.mybatis.mapper2包中提供不同的Mapper,Service中注入不同的Mapper就可以操作不同的数据源。

2.5.4 mapper创建

org.sang.mybatis.mapper1中的mapper:

public interface UserMapperOne {
    List<User> getAllUser();
}

对应的XML文件:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.sang.mybatis.mapper1.UserMapperOne">
    <select id="getAllUser" resultType="org.sang.mybatis.model.User">
        select * from t_user;
    </select>
</mapper>

org.sang.mybatis.mapper2中的mapper:

public interface UserMapper {
    List<User> getAllUser();
}

对应的XML文件:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.sang.mybatis.mapper2.UserMapper">
    <select id="getAllUser" resultType="org.sang.mybatis.model.User">
        select * from t_user;
    </select>
</mapper>

接下来,在Service中注入两个不同的Mapper,不同的Mapper将操作不同的数据源。

3. 整合Jpa

3.1 JPA是什么

  • Java Persistence API:用于对象持久化的 API

  • Java EE 5.0 平台标准的 ORM 规范,使得应用程序以统一的方式访问持久层

    img

3.2 JPA和Hibernate的关系

  • JPA 是 Hibernate 的一个抽象(就像JDBC和JDBC驱动的关系)
  • JPA 是规范:JPA 本质上就是一种 ORM 规范,不是ORM 框架,这是因为 JPA 并未提供 ORM 实现,它只是制订了一些规范,提供了一些编程的 API 接口,但具体实现则由 ORM 厂商提供实现;
  • Hibernate 是实现:Hibernate 除了作为 ORM 框架之外,它也是一种 JPA 实现
  • 从功能上来说, JPA 是 Hibernate 功能的一个子集

3.3 JPA的供应商

JPA 的目标之一是制定一个可以由很多供应商实现的 API,Hibernate 3.2+、TopLink 10.1+ 以及 OpenJPA 都提供了 JPA 的实现,Jpa 供应商有很多,常见的有如下四种:

  • Hibernate
    JPA 的始作俑者就是 Hibernate 的作者,Hibernate 从 3.2 开始兼容 JPA。
  • OpenJPA
    OpenJPA 是 Apache 组织提供的开源项目。
  • TopLink
    TopLink 以前需要收费,如今开源了。
  • EclipseLink

3.4 JPA的优势

  • 标准化: 提供相同的 API,这保证了基于JPA 开发的企业应用能够经过少量的修改就能够在不同的 JPA 框架下运行。
  • 简单易用,集成方便: JPA 的主要目标之一就是提供更加简单的编程模型,在 JPA 框架下创建实体和创建 Java 类一样简单,只需要使用 javax.persistence.Entity 进行注解;JPA 的框架和接口也都非常简单。
  • 可媲美JDBC的查询能力: JPA的查询语言是面向对象的,JPA定义了独特的JPQL,而且能够支持批量更新和修改、JOIN、GROUP BY、HAVING 等通常只有 SQL 才能够提供的高级查询特性,甚至还能够支持子查询。
  • 支持面向对象的高级特性: JPA 中能够支持面向对象的高级特性,如类之间的继承、多态和类之间的复杂关系,最大限度的使用面向对象的模型

3.5 JPA包含的技术

  • ORM 映射元数据:JPA 支持 XML 和 JDK 5.0 注解两种元数据的形式,元数据描述对象和表之间的映射关系,框架据此将实体对象持久化到数据库表中。
  • JPA 的 API:用来操作实体对象,执行CRUD操作,框架在后台完成所有的事情,开发者从繁琐的 JDBC 和 SQL 代码中解脱出来。
  • 查询语言(JPQL):这是持久化操作中很重要的一个方面,通过面向对象而非面向数据库的查询语言查询数据,避免程序和具体的 SQL 紧密耦合。

3.6 Spring Data

Spring Data 是 Spring 的一个子项目。用于简化数据库访问,支持NoSQL 和 关系数据存储。其主要目标是使数据库的访问变得方便快捷。Spring Data 具有如下特点:

  • SpringData 项目支持 NoSQL 存储:
    MongoDB (文档数据库)
    Neo4j(图形数据库)
    Redis(键/值存储)
    Hbase(列族数据库)
  • SpringData 项目所支持的关系数据存储技术:
    JDBC
    JPA
  • Spring Data Jpa 致力于减少数据访问层 (DAO) 的开发量. 开发者唯一要做的,就是声明持久层的接口,其他都交给 Spring Data JPA 来帮你完成!
  • 框架怎么可能代替开发者实现业务逻辑呢?比如:当有一个 UserDao.findUserById() 这样一个方法声明,大致应该能判断出这是根据给定条件的 ID 查询出满足条件的 User 对象。Spring Data JPA 做的便是规范方法的名字,根据符合规范的名字来确定方法需要实现什么样的逻辑。

3.7 Jpa 的故事

为了让大伙彻底把这两个东西学会,这里我就先来介绍单纯的Jpa使用,然后我们再结合 Spring Data 来看 Jpa如何使用。

整体步骤如下:

1.使用 IntelliJ IDEA 创建项目,创建时选择 JavaEE Persistence ,如下:
img

2.创建成功后,添加依赖jar,由于 Jpa 只是一个规范,因此我们说用Jpa实际上必然是用Jpa的某一种实现,那么是哪一种实现呢?当然就是Hibernate了,所以添加的jar,实际上来自 Hibernate,如下:

img

3.添加实体类

接下来在项目中添加实体类,如下:

@Entity(name = "t_book")
public class Book {
    private Long id;
    private String name;
    private String author;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    public Long getId() {
        return id;
    }
    // 省略其他getter/setter
}

首先@Entity注解表示这是一个实体类,那么在项目启动时会自动针对该类生成一张表,默认的表名为类名,@Entity注解的name属性表示自定义生成的表名。@Id注解表示这个字段是一个id,@GeneratedValue注解表示主键的自增长策略,对于类中的其他属性,默认都会根据属性名在表中生成相应的字段,字段名和属性名相同,如果开发者想要对字段进行定制,可以使用@Column注解,去配置字段的名称,长度,是否为空等等。

4.创建 persistence.xml 文件

JPA 规范要求在类路径的 META-INF 目录下放置persistence.xml,文件的名称是固定的

<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://java.sun.com/xml/ns/persistence" version="2.0">
    <persistence-unit name="NewPersistenceUnit" transaction-type="RESOURCE_LOCAL">
        <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
        <class>org.sang.Book</class>
        <properties>
            <property name="hibernate.connection.url"
                      value="jdbc:mysql:///jpa01?useUnicode=true&amp;characterEncoding=UTF-8"/>
            <property name="hibernate.connection.driver_class" value="com.mysql.jdbc.Driver"/>
            <property name="hibernate.connection.username" value="root"/>
            <property name="hibernate.connection.password" value="123"/>
            <property name="hibernate.archive.autodetection" value="class"/>
            <property name="hibernate.show_sql" value="true"/>
            <property name="hibernate.format_sql" value="true"/>
            <property name="hibernate.hbm2ddl.auto" value="update"/>
        </properties>
    </persistence-unit>
</persistence>

注意:

  • persistence-unit 的name 属性用于定义持久化单元的名字, 必填。
  • transaction-type:指定 JPA 的事务处理策略。RESOURCE_LOCAL:默认值,数据库级别的事务,只能针对一种数据库,不支持分布式事务。如果需要支持分布式事务,使用JTA:transaction-type=”JTA”
  • class节点表示显式的列出实体类
  • properties中的配置分为两部分:数据库连接信息以及Hibernate信息
  1. 执行持久化操作
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("NewPersistenceUnit");
EntityManager manager = entityManagerFactory.createEntityManager();
EntityTransaction transaction = manager.getTransaction();
transaction.begin();
Book book = new Book();
book.setAuthor("罗贯中");
book.setName("三国演义");
manager.persist(book);
transaction.commit();
manager.close();
entityManagerFactory.close();

这里首先根据配置文件创建出来一个 EntityManagerFactory ,然后再根据 EntityManagerFactory 的实例创建出来一个 EntityManager ,然后再开启事务,调用 EntityManager 中的 persist 方法执行一次持久化操作,最后提交事务,执行完这些操作后,数据库中旧多出来一个 t_book 表,并且表中有一条数据。

关于 JPQL

  • JPQL语言,即 Java Persistence Query Language 的简称。JPQL 是一种和 SQL 非常类似的中间性和对象化查询语言,它最终会被编译成针对不同底层数据库的 SQL 查询,从而屏蔽不同数据库的差异。JPQL语言的语句可以是 select 语句、update 语句或delete语句,它们都通过 Query 接口封装执行。

  • Query接口封装了执行数据库查询的相关方法。调用 EntityManager 的 createQuery、create NamedQuery 及 createNativeQuery 方法可以获得查询对象,进而可调用 Query 接口的相关方法来执行查询操作。

  • Query接口的主要方法如下:

    • int executeUpdate(); 用于执行update或delete语句。
    • List getResultList(); 用于执行select语句并返回结果集实体列表。
    • Object getSingleResult(); 用于执行只返回单个结果实体的select语句。
    • Query setFirstResult(int startPosition); 用于设置从哪个实体记录开始返回查询结果。
    • Query setMaxResults(int maxResult); 用于设置返回结果实体的最大数。与setFirstResult结合使用可实现分页查询。
    • Query setFlushMode(FlushModeType flushMode); 设置查询对象的Flush模式。参数可以取2个枚举值:FlushModeType.AUTO 为自动更新数据库记录,FlushMode Type.COMMIT 为直到提交事务时才更新数据库记录。
    • setHint(String hintName, Object value); 设置与查询对象相关的特定供应商参数或提示信息。参数名及其取值需要参考特定 JPA 实现库提供商的文档。如果第二个参数无效将抛出IllegalArgumentException异常。
    • setParameter(int position, Object value); 为查询语句的指定位置参数赋值。Position 指定参数序号,value 为赋给参数的值。
    • setParameter(int position, Date d, TemporalType type); 为查询语句的指定位置参数赋 Date 值。Position 指定参数序号,value 为赋给参数的值,temporalType 取 TemporalType 的枚举常量,包括 DATE、TIME 及 TIMESTAMP 三个,,用于将 Java 的 Date 型值临时转换为数据库支持的日期时间类型(java.sql.Date、java.sql.Time及java.sql.Timestamp)。
    • setParameter(int position, Calendar c, TemporalType type); 为查询语句的指定位置参数赋 Calenda r值。position 指定参数序号,value 为赋给参数的值,temporalType 的含义及取舍同前。
    • setParameter(String name, Object value); 为查询语句的指定名称参数赋值。
    • setParameter(String name, Date d, TemporalType type); 为查询语句的指定名称参数赋 Date 值,用法同前。
      setParameter(String name, Calendar c, TemporalType type); 为查询语句的指定名称参数设置Calendar值。name为参数名,其它同前。该方法调用时如果参数位置或参数名不正确,或者所赋的参数值类型不匹配,将抛出 IllegalArgumentException 异常。

JPQL 举例

和在 SQL 中一样,JPQL 中的 select 语句用于执行查询。其语法可表示为:
select_clause form_clause [where_clause] [groupby_clause] [having_clause] [orderby_clause]

其中:

  1. from 子句是查询语句的必选子句。
  2. select 用来指定查询返回的结果实体或实体的某些属性。
  3. from 子句声明查询源实体类,并指定标识符变量(相当于SQL表的别名)。
  4. 如果不希望返回重复实体,可使用关键字 distinct 修饰。select、from 都是 JPQL 的关键字,通常全大写或全小写,建议不要大小写混用。

在 JPQL 中,查询所有实体的 JPQL 查询语句很简单,如下:
select o from Order o 或 select o from Order as o
这里关键字 as 可以省去,标识符变量的命名规范与 Java 标识符相同,且区分大小写,调用 EntityManager 的 createQuery() 方法可创建查询对象,接着调用 Query 接口的 getResultList() 方法就可获得查询结果集,如下:

Query query = entityManager.createQuery( "select o from Order o"); 
List orders = query.getResultList();
Iterator iterator = orders.iterator();
while(iterator.hasNext() ) {
  // 处理Order
}

其他方法的与此类似,这里不再赘述。

3.8 Spring Data 的故事

在 Spring Boot 中,Spring Data Jpa 官方封装了太多东西了,导致很多人用的时候不知道底层到底是怎么配置的,本文就和大伙来看看在手工的Spring环境下,Spring Data Jpa要怎么配置,配置完成后,用法和 Spring Boot 中的用法是一致的。

基本环境搭建

首先创建一个普通的Maven工程,并添加如下依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-orm</artifactId>
        <version>5.0.2.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-oxm</artifactId>
        <version>5.0.2.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-aop</artifactId>
        <version>5.0.2.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-aspects</artifactId>
        <version>5.0.2.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>5.1.27</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context-support</artifactId>
        <version>5.0.2.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-expression</artifactId>
        <version>5.0.2.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.hibernate</groupId>
        <artifactId>hibernate-core</artifactId>
        <version>5.2.12.Final</version>
    </dependency>
    <dependency>
        <groupId>org.hibernate</groupId>
        <artifactId>hibernate-jpamodelgen</artifactId>
        <version>5.2.12.Final</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>1.0.29</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.data</groupId>
        <artifactId>spring-data-jpa</artifactId>
        <version>1.11.3.RELEASE</version>
    </dependency>
</dependencies>

这里除了 Jpa 的依赖之外,就是Spring Data Jpa 的依赖了。

接下来创建一个 User 实体类,创建方式参考 Jpa中实体类的创建方式,这里不再赘述。

接下来在resources目录下创建一个applicationContext.xml文件,并配置Spring和Jpa,如下:

<context:property-placeholder location="classpath:db.properties"/>
<context:component-scan base-package="org.sang"/>
<bean class="com.alibaba.druid.pool.DruidDataSource" id="dataSource">
    <property name="driverClassName" value="${db.driver}"/>
    <property name="url" value="${db.url}"/>
    <property name="username" value="${db.username}"/>
    <property name="password" value="${db.password}"/>
</bean>
<bean class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean" id="entityManagerFactory">
    <property name="dataSource" ref="dataSource"/>
    <property name="jpaVendorAdapter">
        <bean class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter"/>
    </property>
    <property name="packagesToScan" value="org.sang.model"/>
    <property name="jpaProperties">
        <props>
            <prop key="hibernate.show_sql">true</prop>
            <prop key="hibernate.format_sql">true</prop>
            <prop key="hibernate.hbm2ddl.auto">update</prop>
            <prop key="hibernate.dialect">org.hibernate.dialect.MySQL57Dialect</prop>
        </props>
    </property>
</bean>
<bean class="org.springframework.orm.jpa.JpaTransactionManager" id="transactionManager">
    <property name="entityManagerFactory" ref="entityManagerFactory"/>
</bean>
<tx:annotation-driven transaction-manager="transactionManager"/>
<!-- 配置jpa -->
<jpa:repositories base-package="org.sang.dao"
                  entity-manager-factory-ref="entityManagerFactory"/>

这里和 Jpa 相关的配置主要是三个,一个是entityManagerFactory,一个是Jpa的事务,还有一个是配置dao的位置,配置完成后,就可以在 org.sang.dao 包下创建相应的 Repository 了,如下:

public interface UserDao extends Repository<User, Long> {
    User getUserById(Long id);
}

getUserById表示根据id去查询User对象,只要我们的方法名称符合类似的规范,就不需要写SQL,具体的规范一会来说。好了,接下来,创建 Service 和 Controller 来调用这个方法,如下:

@Service
@Transactional
public class UserService {
    @Resource
    UserDao userDao;

    public User getUserById(Long id) {
        return userDao.getUserById(id);
    }
}
public void test1() {
    ClassPathXmlApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml");
    UserService userService = ctx.getBean(UserService.class);
    User user = userService.getUserById(1L);
    System.out.println(user);
}

这样,就可以查询到id为1的用户了。

Repository

上文我们自定义的 UserDao 实现了 Repository 接口,这个 Repository 接口是什么来头呢?

首先来看 Repository 的一个继承关系图:

img

可以看到,实现类不少。那么到底如何理解 Repository 呢?

  1. Repository 接口是 Spring Data 的一个核心接口,它不提供任何方法,开发者需要在自己定义的接口中声明需要的方法 public interface Repository<T, ID extends Serializable> { }
  2. 若我们定义的接口继承了 Repository, 则该接口会被 IOC 容器识别为一个 Repository Bean,进而纳入到 IOC 容器中,进而可以在该接口中定义满足一定规范的方法。
  3. Spring Data可以让我们只定义接口,只要遵循 Spring Data 的规范,就无需写实现类。
  4. 与继承 Repository 等价的一种方式,就是在持久层接口上使用 @RepositoryDefinition 注解,并为其指定 domainClass 和 idClass 属性。像下面这样:
@RepositoryDefinition(domainClass = User.class, idClass = Long.class)
public interface UserDao
{
    User findById(Long id);
    List<User> findAll();
}

基础的 Repository 提供了最基本的数据访问功能,其几个子接口则扩展了一些功能,它的几个常用的实现类如下:

  • CrudRepository: 继承 Repository,实现了一组 CRUD 相关的方法
  • PagingAndSortingRepository: 继承 CrudRepository,实现了一组分页排序相关的方法
  • JpaRepository: 继承 PagingAndSortingRepository,实现一组 JPA 规范相关的方法
  • 自定义的 XxxxRepository 需要继承 JpaRepository,这样的 XxxxRepository 接口就具备了通用的数据访问控制层的能力。
  • JpaSpecificationExecutor: 不属于Repository体系,实现一组 JPA Criteria 查询相关的方法

方法定义规范

简单条件查询
  • 按照 Spring Data 的规范,查询方法以 find read get 开头
  • 涉及条件查询时,条件的属性用条件关键字连接,要注意的是:条件属性以首字母大写

例如:定义一个 Entity 实体类:

class User
   private String firstName; 
   private String lastName; 
}

使用And条件连接时,条件的属性名称与个数要与参数的位置与个数一一对应,如下:

findByLastNameAndFirstName(String lastName,String firstName);
  • 支持属性的级联查询. 若当前类有符合条件的属性, 则优先使用, 而不使用级联属性. 若需要使用级联属性, 则属性之间使用 _ 进行连接.

查询举例:
1.按照id查询

User getUserById(Long id);
User getById(Long id);

2.查询所有年龄小于90岁的人

List<User> findByAgeLessThan(Long age);

3.查询所有姓赵的人

List<User> findByUsernameStartingWith(String u);

4.查询所有姓赵的、并且id大于50的人

List<User> findByUsernameStartingWithAndIdGreaterThan(String name, Long id);

5.查询所有姓名中包含”上”字的人

List<User> findByUsernameContaining(String name);

6.查询所有姓赵的或者年龄大于90岁的

List<User> findByUsernameStartingWithOrAgeGreaterThan(String name, Long age);

7.查询所有角色为1的用户

List<User> findByRole_Id(Long id);
支持的关键字

支持的查询关键字如下图:

img

查询方法流程解析

为什么写上方法名,JPA就知道你想干嘛了呢?假如创建如下的查询:findByUserDepUuid(),框架在解析该方法时,首先剔除 findBy,然后对剩下的属性进行解析,假设查询实体为Doc:

  1. 先判断 userDepUuid (根据 POJO 规范,首字母变为小写)是否为查询实体的一个属性,如果是,则表示根据该属性进行查询;如果没有该属性,继续第二步;
  2. 从右往左截取第一个大写字母开头的字符串(此处为Uuid),然后检查剩下的字符串是否为查询实体的一个属性,如果是,则表示根据该属性进行查询;如果没有该属性,则重复第二步,继续从右往左截取;最后假设 user 为查询实体的一个属性;
  3. 接着处理剩下部分(DepUuid),先判断 user 所对应的类型是否有depUuid属性,如果有,则表示该方法最终是根据 “ Doc.user.depUuid” 的取值进行查询;否则继续按照步骤 2 的规则从右往左截取,最终表示根据 “Doc.user.dep.uuid” 的值进行查询。
  4. 可能会存在一种特殊情况,比如 Doc包含一个 user 的属性,也有一个 userDep 属性,此时会存在混淆。可以明确在属性之间加上 “_” 以显式表达意图,比如 “findByUser_DepUuid()” 或者 “findByUserDep_uuid()”
  5. 还有一些特殊的参数:例如分页或排序的参数:
Page<UserModel> findByName(String name, Pageable pageable);  
List<UserModel> findByName(String name, Sort sort);

@Query注解

有的时候,这里提供的查询关键字并不能满足我们的查询需求,这个时候就可以使用 @Query 关键字,来自定义查询 SQL,例如查询Id最大的User:

@Query("select u from t_user u where id=(select max(id) from t_user)")
User getMaxIdUser();

如果查询有参数的话,参数有两种不同的传递方式:

1.利用下标索引传参,索引参数如下所示,索引值从1开始,查询中 ”?X” 个数需要与方法定义的参数个数相一致,并且顺序也要一致:

@Query("select u from t_user u where id>?1 and username like ?2")
List<User> selectUserByParam(Long id, String name);

2.命名参数(推荐):这种方式可以定义好参数名,赋值时采用@Param(“参数名”),而不用管顺序:

@Query("select u from t_user u where id>:id and username like :name")
List<User> selectUserByParam2(@Param("name") String name, @Param("id") Long id);

查询时候,也可以是使用原生的SQL查询,如下:

@Query(value = "select * from t_user",nativeQuery = true)
List<User> selectAll();

@Modifying注解

涉及到数据修改操作,可以使用 @Modifying 注解,@Query 与 @Modifying 这两个 annotation一起声明,可定义个性化更新操作,例如涉及某些字段更新时最为常用,示例如下:

@Modifying
@Query("update t_user set age=:age where id>:id")
int updateUserById(@Param("age") Long age, @Param("id") Long id);

注意:

  1. 可以通过自定义的 JPQL 完成 UPDATE 和 DELETE 操作. 注意: JPQL 不支持使用 INSERT
  2. 方法的返回值应该是 int,表示更新语句所影响的行数
  3. 在调用的地方必须加事务,没有事务不能正常执行
  4. 默认情况下, Spring Data 的每个方法上有事务, 但都是一个只读事务. 他们不能完成修改操作

说到这里,再来顺便说说Spring Data 中的事务问题:

  1. Spring Data 提供了默认的事务处理方式,即所有的查询均声明为只读事务。
  2. 对于自定义的方法,如需改变 Spring Data 提供的事务默认方式,可以在方法上添加 @Transactional 注解。
  3. 进行多个 Repository 操作时,也应该使它们在同一个事务中处理,按照分层架构的思想,这部分属于业务逻辑层,因此,需要在Service 层实现对多个 Repository 的调用,并在相应的方法上声明事务。

Spring Boot中的数据持久化方案前面给大伙介绍了两种了,一个是JdbcTemplate,还有一个MyBatis,JdbcTemplate配置简单,使用也简单,但是功能也非常有限,MyBatis则比较灵活,功能也很强大,据我所知,公司采用MyBatis做数据持久化的相当多,但是MyBatis并不是唯一的解决方案,除了MyBatis之外,还有另外一个东西,那就是Jpa,松哥也有一些朋友在公司里使用Jpa来做数据持久化,本文就和大伙来说说Jpa如何实现数据持久化。

3.9 整合Jpa

首先需要向大伙介绍一下Jpa,Jpa(Java Persistence API)Java持久化API,它是一套ORM规范,而不是具体的实现,Jpa的江湖地位类似于JDBC,只提供规范,所有的数据库厂商提供实现(即具体的数据库驱动),Java领域,小伙伴们熟知的ORM框架可能主要是Hibernate,实际上,除了Hibernate之外,还有很多其他的ORM框架,例如:

  • Batoo JPA
  • DataNucleus (formerly JPOX)
  • EclipseLink (formerly Oracle TopLink)
  • IBM, for WebSphere Application Server
  • JBoss with Hibernate
  • Kundera
  • ObjectDB
  • OpenJPA
  • OrientDB from Orient Technologies
  • Versant Corporation JPA (not relational, object database)

Hibernate只是ORM框架的一种,上面列出来的ORM框架都是支持JPA2.0规范的ORM框架。既然它是一个规范,不是具体的实现,那么必然就不能直接使用(类似于JDBC不能直接使用,必须要加了驱动才能用),我们使用的是具体的实现,在这里我们采用的实现实际上还是Hibernate。

Spring Boot中使用的Jpa实际上是Spring Data Jpa,Spring Data是Spring家族的一个子项目,用于简化SQL和NoSQL的访问,在Spring Data中,只要你的方法名称符合规范,它就知道你想干嘛,不需要自己再去写SQL。

工程创建

创建Spring Boot工程,添加Web、Jpa以及MySQL驱动依赖,如下:

img

工程创建好之后,添加Druid依赖,完整的依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.10</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.28</version>
    <scope>runtime</scope>
</dependency>

如此,工程就算创建成功了。

基本配置

工程创建完成后,只需要在application.properties中进行数据库基本信息配置以及Jpa基本配置,如下:

# 数据库的基本配置
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mysql:///test01?useUnicode=true&characterEncoding=UTF-8
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
# JPA配置
spring.jpa.database=mysql
# 在控制台打印SQL
spring.jpa.show-sql=true
# 数据库平台
spring.jpa.database-platform=mysql
# 每次启动项目时,数据库初始化策略
spring.jpa.hibernate.ddl-auto=update
# 指定默认的存储引擎为InnoDB
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect

注意这里和JdbcTemplate以及MyBatis比起来,多了Jpa配置,Jpa配置含义我都注释在代码中了,这里不再赘述,需要强调的是,最后一行配置,默认情况下,自动创建表的时候会使用MyISAM做表的引擎,如果配置了数据库方言为MySQL57Dialect,则使用InnoDB做表的引擎

好了,配置完成后,我们的Jpa差不多就可以开始用了

基本用法

ORM(Object Relational Mapping)框架表示对象关系映射,使用ORM框架我们不必再去创建表,框架会自动根据当前项目中的实体类创建相应的数据表。因此,我这里首先创建一个User对象,如下:

@Entity(name = "t_user")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    @Column(name = "name")
    private String username;
    private String address;
    //省略getter/setter
}

首先@Entity注解表示这是一个实体类,那么在项目启动时会自动针对该类生成一张表,默认的表名为类名,@Entity注解的name属性表示自定义生成的表名。@Id注解表示这个字段是一个id,@GeneratedValue注解表示主键的自增长策略,对于类中的其他属性,默认都会根据属性名在表中生成相应的字段,字段名和属性名相同,如果开发者想要对字段进行定制,可以使用@Column注解,去配置字段的名称,长度,是否为空等等。

做完这一切之后,启动Spring Boot项目,就会发现数据库中多了一个名为t_user的表了。

针对该表的操作,则需要我们提供一个Repository,如下:

public interface UserDao extends JpaRepository<User,Integer> {
    List<User> getUserByAddressEqualsAndIdLessThanEqual(String address, Integer id);

    @Query(value = "select * from t_user where id=(select max(id) from t_user)",nativeQuery = true)
    User maxIdUser();
}

这里,自定义UserDao接口继承自JpaRepository,JpaRepository提供了一些基本的数据操作方法,例如保存,更新,删除,分页查询等,开发者也可以在接口中自己声明相关的方法,只需要方法名称符合规范即可,在Spring Data中,只要按照既定的规范命名方法,Spring Data Jpa就知道你想干嘛,这样就不用写SQL了,那么规范是什么呢?参考下图:

img

当然,这种方法命名主要是针对查询,但是一些特殊需求,可能并不能通过这种方式解决,例如想要查询id最大的用户,这时就需要开发者自定义查询SQL了,如上代码所示,自定义查询SQL,使用@Query注解,在注解中写自己的SQL,默认使用的查询语言不是SQL,而是JPQL,这是一种数据库平台无关的面向对象的查询语言,有点定位类似于Hibernate中的HQL,在@Query注解中设置nativeQuery属性为true则表示使用原生查询,即大伙所熟悉的SQL。上面代码中的只是一个很简单的例子,还有其他一些点,例如如果这个方法中的SQL涉及到数据操作,则需要使用@Modifying注解。

好了,定义完Dao之后,接下来就可以将UserDao注入到Controller中进行测试了(这里为了省事,就没有提供Service了,直接将UserDao注入到Controller中)。

@RestController
public class UserController {
    @Autowired
    UserDao userDao;
    @PostMapping("/")
    public void addUser() {
        User user = new User();
        user.setId(1);
        user.setUsername("张三");
        user.setAddress("深圳");
        userDao.save(user);
    }

    @DeleteMapping("/")
    public void deleteById() {
        userDao.deleteById(1);
    }
    @PutMapping("/")
    public void updateUser() {
        User user = userDao.getOne(1);
        user.setUsername("李四");
        userDao.flush();
    }

    @GetMapping("/test1")
    public void test1() {
        List<User> all = userDao.findAll();
        System.out.println(all);
    }

    @GetMapping("/test2")
    public void test2() {
        List<User> list = userDao.getUserByAddressEqualsAndIdLessThanEqual("广州", 2);
        System.out.println(list);
    }
    @GetMapping("/test3")
    public void test3() {
        User user = userDao.maxIdUser();
        System.out.println(user);
    }
}

如此之后,即可查询到需要的数据。

总结

在和Spring框架整合时,如果用到ORM框架,大部分人可能都是首选Hibernate,实际上,在和Spring+SpringMVC整合时,也可以选择Spring Data Jpa做数据持久化方案,用法和本文所述基本是一样的,Spring Boot只是将Spring Data Jpa的配置简化了,因此,很多初学者对Spring Data Jpa觉得很神奇,但是又觉得无从下手,其实,此时可以回到Spring框架,先去学习Jpa,再去学习Spring Data Jpa,这是给初学者的一点建议。

3.10 整合多数据源

本文是Spring Boot整合数据持久化方案的最后一篇,主要和大伙来聊聊Spring Boot整合Jpa多数据源问题。在Spring Boot整合JbdcTemplate多数据源、Spring Boot整合MyBatis多数据源以及Spring Boot整合Jpa多数据源这三个知识点中,整合Jpa多数据源算是最复杂的一种,也是很多人在配置时最容易出错的一种。

3.10.1 工程创建

首先是创建一个Spring Boot工程,创建时添加基本的Web、Jpa以及MySQL依赖,如下:

img

创建完成后,添加Druid依赖,这里和前文的要求一样,要使用专为Spring Boot打造的Druid,大伙可能发现了,如果整合多数据源一定要使用这个依赖,因为这个依赖中才有DruidDataSourceBuilder,最后还要记得锁定数据库依赖的版本,因为可能大部分人用的还是5.x的MySQL而不是8.x。完整依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.10</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.28</version>
    <scope>runtime</scope>
</dependency>

如此之后,工程就创建成功了。

3.10.2 基本配置

在基本配置中,我们首先来配置多数据源基本信息以及DataSource,首先在application.properties中添加如下配置信息:

#  数据源一
spring.datasource.one.username=root
spring.datasource.one.password=root
spring.datasource.one.url=jdbc:mysql:///test01?useUnicode=true&characterEncoding=UTF-8
spring.datasource.one.type=com.alibaba.druid.pool.DruidDataSource

#  数据源二
spring.datasource.two.username=root
spring.datasource.two.password=root
spring.datasource.two.url=jdbc:mysql:///test02?useUnicode=true&characterEncoding=UTF-8
spring.datasource.two.type=com.alibaba.druid.pool.DruidDataSource

# Jpa配置
spring.jpa.properties.database=mysql
spring.jpa.properties.show-sql=true
spring.jpa.properties.database-platform=mysql
spring.jpa.properties.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect

这里Jpa的配置和上文相比key中多了properties,多数据源的配置和前文一致,然后接下来配置两个DataSource,如下:

@Configuration
public class DataSourceConfig {
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.one")
    @Primary
    DataSource dsOne() {
        return DruidDataSourceBuilder.create().build();
    }
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.two")
    DataSource dsTwo() {
        return DruidDataSourceBuilder.create().build();
    }
}

这里的配置和前文的多数据源配置基本一致,但是注意多了一个在Spring中使用较少的注解@Primary,这个注解一定不能少,否则在项目启动时会出错,@Primary表示当某一个类存在多个实例时,优先使用哪个实例。

3.10.3 多数据源配置

接下来配置Jpa的基本信息,这里两个数据源,我分别在两个类中来配置,先来看第一个配置:

@Configuration
@EnableJpaRepositories(basePackages = "org.sang.jpa.dao",entityManagerFactoryRef = "localContainerEntityManagerFactoryBeanOne",transactionManagerRef = "platformTransactionManagerOne")
public class JpaConfigOne {
    @Autowired
    @Qualifier(value = "dsOne")
    DataSource dsOne;

    @Autowired
    JpaProperties jpaProperties;


    @Bean
    @Primary
    LocalContainerEntityManagerFactoryBean localContainerEntityManagerFactoryBeanOne(EntityManagerFactoryBuilder builder) {
        return builder.dataSource(dsOne)
                .packages("org.sang.jpa.model")
                .properties(jpaProperties.getProperties())
                .persistenceUnit("pu1")
                .build();
    }

    @Bean
    PlatformTransactionManager platformTransactionManagerOne(EntityManagerFactoryBuilder builder) {
        LocalContainerEntityManagerFactoryBean factoryBeanOne = localContainerEntityManagerFactoryBeanOne(builder);
        return new JpaTransactionManager(factoryBeanOne.getObject());
    }
}

首先这里注入dsOne,再注入JpaProperties,JpaProperties是系统提供的一个实例,里边的数据就是我们在application.properties中配置的jpa相关的配置。然后我们提供两个Bean,分别是LocalContainerEntityManagerFactoryBean和PlatformTransactionManager事务管理器,不同于MyBatis和JdbcTemplate,在Jpa中,事务一定要配置。在提供LocalContainerEntityManagerFactoryBean的时候,需要指定packages,这里的packages指定的包就是这个数据源对应的实体类所在的位置,另外在这里配置类上通过@EnableJpaRepositories注解指定dao所在的位置,以及LocalContainerEntityManagerFactoryBean和PlatformTransactionManager分别对应的引用的名字。

好了,这样第一个就配置好了,第二个基本和这个类似,主要有几个不同点:

  • dao的位置不同
  • persistenceUnit不同
  • 相关bean的名称不同

注意实体类可以共用。

代码如下:

@Configuration
@EnableJpaRepositories(basePackages = "org.sang.jpa.dao2",entityManagerFactoryRef = "localContainerEntityManagerFactoryBeanTwo",transactionManagerRef = "platformTransactionManagerTwo")
public class JpaConfigTwo {
    @Autowired
    @Qualifier(value = "dsTwo")
    DataSource dsTwo;

    @Autowired
    JpaProperties jpaProperties;


    @Bean
    LocalContainerEntityManagerFactoryBean localContainerEntityManagerFactoryBeanTwo(EntityManagerFactoryBuilder builder) {
        return builder.dataSource(dsTwo)
                .packages("org.sang.jpa.model")
                .properties(jpaProperties.getProperties())
                .persistenceUnit("pu2")
                .build();
    }

    @Bean
    PlatformTransactionManager platformTransactionManagerTwo(EntityManagerFactoryBuilder builder) {
        LocalContainerEntityManagerFactoryBean factoryBeanTwo = localContainerEntityManagerFactoryBeanTwo(builder);
        return new JpaTransactionManager(factoryBeanTwo.getObject());
    }
}

接下来,在对应位置分别提供相关的实体类和dao即可,数据源一的dao如下:

package org.sang.jpa.dao;

public interface UserDao extends JpaRepository<User,Integer> {
    List<User> getUserByAddressEqualsAndIdLessThanEqual(String address, Integer id);

    @Query(value = "select * from t_user where id=(select max(id) from t_user)",nativeQuery = true)
    User maxIdUser();
}

数据源二的dao如下:

package org.sang.jpa.dao2;

public interface UserDao2 extends JpaRepository<User,Integer> {
    List<User> getUserByAddressEqualsAndIdLessThanEqual(String address, Integer id);

    @Query(value = "select * from t_user where id=(select max(id) from t_user)",nativeQuery = true)
    User maxIdUser();
}

共同的实体类如下:

package org.sang.jpa.model;

@Entity(name = "t_user")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private String username;
    private String address;
    //省略getter/setter
}

到此,所有的配置就算完成了,接下来就可以在Service中注入不同的UserDao,不同的UserDao操作不同的数据源。

SpringBoot整合NoSQL

1. 整合Redis

1.1 创建工程

创建工程,引入 Redis 依赖:

img

创建成功后,还需要手动引入 commos-pool2 的依赖,因此最终完整的 pom.xml 依赖如下:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

这里主要就是引入了 Spring Data Redis + 连接池。

1.2 配置 Redis 信息

接下来配置 Redis 的信息,信息包含两方面,一方面是 Redis 的基本信息,另一方面则是连接池信息:

spring.redis.database=0
#没有就空着
spring.redis.password=123
spring.redis.port=6379
spring.redis.host=192.168.66.128
spring.redis.lettuce.pool.min-idle=5
spring.redis.lettuce.pool.max-idle=10
spring.redis.lettuce.pool.max-active=8
spring.redis.lettuce.pool.max-wait=1ms
spring.redis.lettuce.shutdown-timeout=100ms

1.3 自动配置

当开发者在项目中引入了 Spring Data Redis ,并且配置了 Redis 的基本信息,此时,自动化配置就会生效。

我们从 Spring Boot 中 Redis 的自动化配置类中就可以看出端倪:

@Configuration
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties(RedisProperties.class)
@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {
        @Bean
        @ConditionalOnMissingBean(name = "redisTemplate")
        public RedisTemplate<Object, Object> redisTemplate(
                        RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
                RedisTemplate<Object, Object> template = new RedisTemplate<>();
                template.setConnectionFactory(redisConnectionFactory);
                return template;
        }
  
        @Bean
        @ConditionalOnMissingBean
        public StringRedisTemplate stringRedisTemplate(
                        RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
                StringRedisTemplate template = new StringRedisTemplate();
                template.setConnectionFactory(redisConnectionFactory);
                return template;
        }
}

这个自动化配置类很好理解:

  • 首先标记这个是一个配置类,同时该配置在 RedisOperations 存在的情况下才会生效(即项目中引入了 Spring Data Redis)
  • 然后导入在 application.properties 中配置的属性
  • 然后再导入连接池信息(如果存在的话)
  • 最后,提供了两个 Bean ,RedisTemplate 和 StringRedisTemplate ,其中 StringRedisTemplate 是 RedisTemplate 的子类,两个的方法基本一致,不同之处主要体现在操作的数据类型不同,RedisTemplate 中的两个泛型都是 Object ,意味者存储的 key 和 value 都可以是一个对象,而 StringRedisTemplate 的 两个泛型都是 String ,意味者 StringRedisTemplate 的 key 和 value 都只能是字符串。如果开发者没有提供相关的 Bean ,这两个配置就会生效,否则不会生效。

1.4 使用

接下来,可以直接在 Service 中注入 StringRedisTemplate 或者 RedisTemplate 来使用:

@Service
public class HelloService {
    @Autowired
    RedisTemplate redisTemplate;
    public void hello() {
        ValueOperations ops = redisTemplate.opsForValue();
        ops.set("k1", "v1");
        Object k1 = ops.get("k1");
        System.out.println(k1);
    }
}

Redis 中的数据操作,大体上来说,可以分为两种:

  • 针对 key 的操作,相关的方法就在 RedisTemplate 中
  • 针对具体数据类型的操作,相关的方法需要首先获取对应的数据类型,获取相应数据类型的操作方法是 opsForXXX

调用该方法就可以将数据存储到 Redis 中去了,如下:

img

k1 前面的字符是由于使用了 RedisTemplate 导致的,RedisTemplate 对 key 进行序列化之后的结果。

RedisTemplate 中,key 默认的序列化方案是 JdkSerializationRedisSerializer 。

而在 StringRedisTemplate 中,key 默认的序列化方案是 StringRedisSerializer ,因此,如果使用 StringRedisTemplate ,默认情况下 key 前面不会有前缀。

不过开发者也可以自行修改 RedisTemplate 中的序列化方案,如下:

@Service
public class HelloService {
    @Autowired
    RedisTemplate redisTemplate;
    public void hello() {
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        ValueOperations ops = redisTemplate.opsForValue();
        ops.set("k1", "v1");
        Object k1 = ops.get("k1");
        System.out.println(k1);
    }
}

当然也可以直接使用 StringRedisTemplate:

@Service
public class HelloService {
    @Autowired
    StringRedisTemplate stringRedisTemplate;
    public void hello2() {
        ValueOperations ops = stringRedisTemplate.opsForValue();
        ops.set("k2", "v2");
        Object k1 = ops.get("k2");
        System.out.println(k1);
    }
}

另外需要注意 ,Spring Boot 的自动化配置,只能配置单机的 Redis ,如果是 Redis 集群,则所有的东西都需要自己手动配置

1.5 解决接口幂等性

利用Service操作Redis

@Service
public class RedisService {
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    public boolean setEx(String key, String value, Long expireTime) {
        boolean result = false;
        try {
            ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
            ops.set(key,value);
            stringRedisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    public boolean exists(String key) {
        return stringRedisTemplate.hasKey(key);
    }

    public boolean remove(String key) {
        if (exists(key)) {
            return stringRedisTemplate.delete(key);
        }
        return false;
    }
}


@Service
public class TokenService {
    @Autowired
    RedisService redisService;
    //创建
    public String createToken() {
        String uuid = UUID.randomUUID().toString();
        redisService.setEx(uuid, uuid, 10000L);
        return uuid;
    }
    //检查
    public boolean checkToken(HttpServletRequest request) throws IdempotentException {
        String token = request.getHeader("token");
        if (StringUtils.isEmpty(token)) {
            token = request.getParameter("token");
            if (StringUtils.isEmpty(token)) {
                throw new IdempotentException("token 不存在");
            }
        }
        if (!redisService.exists(token)) {
            throw new IdempotentException("重复操作!");
        }
        boolean remove = redisService.remove(token);
        if (!remove) {
            throw new IdempotentException("重复操作!");
        }
        return true;
    }
}

自定义注解

//有此方法代表需要处理幂等性问题
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
}

自定义异常

public class IdempotentException extends Exception {
    public IdempotentException(String message) {
        super(message);
    }
}

//全局异常处理器
@RestControllerAdvice
public class GlobalException {
    @ExceptionHandler(IdempotentException.class)
    public String idempotentException(IdempotentException e) {
        return e.getMessage();
    }
}

利用拦截器的解决方案

@Component
public class IdempotentInterceptor implements HandlerInterceptor {
    @Autowired
    TokenService tokenService;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //不是直接放行
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        Method method = ((HandlerMethod) handler).getMethod();
        AutoIdempotent idempotent = method.getAnnotation(AutoIdempotent.class);
        //自定义的注解不为空,说明加了注解,要进行幂等性处理
        if (idempotent != null) {
            try {
                return tokenService.checkToken(request);
            } catch (IdempotentException e) {
                throw e;
            }
        }
        return true;
    }
}

配置类

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    IdempotentInterceptor idempotentInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(idempotentInterceptor).addPathPatterns("/**");
    }
}

测试类

@RestController
public class HelloController {
    @Autowired
    TokenService tokenService;

    @GetMapping("/gettoken")
    public String getToken() {
        return tokenService.createToken();
    }

    @PostMapping("/hello")
    @AutoIdempotent
    public String hello() {
        return "hello";
    }

    @PostMapping("/hello2")
    public String hello2() {
        return "hello2";
    }
}

利用AOP的解决方案

因为要使用AOP,把IdempotentInterceptor上的@Component注解掉,让其失效,同时让WebMvcConfig也失效,然后创建即可

@Component
@Aspect
public class IdempotentAspect {
    @Autowired
    TokenService tokenService;

    //加了自定义注解的
    @Pointcut("@annotation(org.javaboy.idempontent.anno.AutoIdempotent)")
    public void pc1() {

    }
    //前置通知
    @Before("pc1()")
    public void before() throws IdempotentException {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        try {
            tokenService.checkToken(request);
        } catch (IdempotentException e) {
            throw e;
        }
    }
}

2. session共享

在传统的单服务架构中,一般来说,只有一个服务器,那么不存在 Session 共享问题,但是在分布式/集群项目中,Session 共享则是一个必须面对的问题,先看一个简单的架构图:

img

在这样的架构中,会出现一些单服务中不存在的问题,例如客户端发起一个请求,这个请求到达 Nginx 上之后,被 Nginx 转发到 Tomcat A 上,然后在 Tomcat A 上往 session 中保存了一份数据,下次又来一个请求,这个请求被转发到 Tomcat B 上,此时再去 Session 中获取数据,发现没有之前的数据。对于这一类问题的解决,思路很简单,就是将各个服务之间需要共享的数据,保存到一个公共的地方(主流方案就是 Redis):

img

当所有 Tomcat 需要往 Session 中写数据时,都往 Redis 中写,当所有 Tomcat 需要读数据时,都从 Redis 中读。这样,不同的服务就可以使用相同的 Session 数据了。

这样的方案,可以由开发者手动实现,即手动往 Redis 中存储数据,手动从 Redis 中读取数据,相当于使用一些 Redis 客户端工具来实现这样的功能,毫无疑问,手动实现工作量还是蛮大的。

一个简化的方案就是使用 Spring Session 来实现这一功能,Spring Session 就是使用 Spring 中的代理过滤器,将所有的 Session 操作拦截下来,自动的将数据 同步到 Redis 中,或者自动的从 Redis 中读取数据。

对于开发者来说,所有关于 Session 同步的操作都是透明的,开发者使用 Spring Session,一旦配置完成后,具体的用法就像使用一个普通的 Session 一样。

2.1 创建工程

首先 创建一个 Spring Boot 工程,引入 Web、Spring Session 以及 Redis:

img

创建成功之后,pom.xml 文件如下:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session-data-redis</artifactId>
    </dependency>
</dependencies>

注意:

这里我使用的 Spring Boot 版本是 2.1.4 ,如果使用当前最新版 Spring Boot2.1.5 的话,除了上面这些依赖之外,需要额外添加 Spring Security 依赖(其他操作不受影响,仅仅只是多了一个依赖,当然也多了 Spring Security 的一些默认认证流程)。

2.2 配置 Redis

spring.redis.host=192.168.66.128
spring.redis.port=6379
#spring.redis.password=123
spring.redis.database=0

这里的 Redis ,我虽然配置了四行,但是考虑到端口默认就是 6379 ,database 默认就是 0,所以真正要配置的,其实就是两行。

2.3 使用

配置完成后 ,就可以使用 Spring Session 了,其实就是使用普通的 HttpSession ,其他的 Session 同步到 Redis 等操作,框架已经自动帮你完成了:

@RestController
public class HelloController {
    @Value("${server.port}")
    Integer port;
    @GetMapping("/set")
    public String set(HttpSession session) {
        session.setAttribute("user", "javaboy");
        return String.valueOf(port);
    }
    @GetMapping("/get")
    public String get(HttpSession session) {
        return session.getAttribute("user") + ":" + port;
    }
}

考虑到一会 Spring Boot 将以集群的方式启动 ,为了获取每一个请求到底是哪一个 Spring Boot 提供的服务,需要在每次请求时返回当前服务的端口号,因此这里我注入了 server.port

接下来 ,项目打包:

img

打包之后,启动项目的两个实例:

java -jar sessionshare-0.0.1-SNAPSHOT.jar --server.port=8080
java -jar sessionshare-0.0.1-SNAPSHOT.jar --server.port=8081

然后先访问 localhost:8080/set8080 这个服务的 Session 中保存一个变量,访问完成后,数据就已经自动同步到 Redis 中 了 :

img

然后,再调用 localhost:8081/get 接口,就可以获取到 8080 服务的 session 中的数据:

img

此时关于 session 共享的配置就已经全部完成了,session 共享的效果我们已经看到了,但是每次访问都是我自己手动切换服务实例,因此,接下来我们来引入 Nginx ,实现服务实例自动切换。

2.4 引入 Nginx

很简单,进入 Nginx 的安装目录的 conf 目录下(默认是在 /usr/local/nginx/conf),编辑 nginx.conf 文件:

img

在这段配置中:

  • upstream 表示配置上游服务器
  • javaboy.org 表示服务器集群的名字,这个可以随意取名字
  • upstream 里边配置的是一个个的单独服务
  • weight 表示服务的权重,意味者将有多少比例的请求从 Nginx 上转发到该服务上
  • location 中的 proxy_pass 表示请求转发的地址,/ 表示拦截到所有的请求,转发转发到刚刚配置好的服务集群中
  • proxy_redirect 表示设置当发生重定向请求时,nginx 自动修正响应头数据(默认是 Tomcat 返回重定向,此时重定向的地址是 Tomcat 的地址,我们需要将之修改使之成为 Nginx 的地址)

配置完成后,将本地的 Spring Boot 打包好的 jar 上传到 Linux ,然后在 Linux 上分别启动两个 Spring Boot 实例:

nohup java -jar sessionshare-0.0.1-SNAPSHOT.jar --server.port=8080 &
nohup java -jar sessionshare-0.0.1-SNAPSHOT.jar --server.port=8081 &

其中

  • nohup 表示当终端关闭时,Spring Boot 不要停止运行
  • & 表示让 Spring Boot 在后台启动

配置完成后,重启 Nginx:

/usr/local/nginx/sbin/nginx -s reload

Nginx 启动成功后,我们首先手动清除 Redis 上的数据,然后访问 192.168.66.128/set 表示向 session 中保存数据,这个请求首先会到达 Nginx 上,再由 Nginx 转发给某一个 Spring Boot 实例:

img

如上,表示端口为 8081Spring Boot 处理了这个 /set 请求,再访问 /get 请求:

img

可以看到,/get 请求是被端口为 8080 的服务所处理的。

2.5 总结

本文主要向大家介绍了 Spring Session 的使用,另外也涉及到一些 Nginx 的使用 ,虽然本文较长,但是实际上 Spring Session 的配置没啥。

我们写了一些代码,也做了一些配置,但是全都和 Spring Session 无关,配置是配置 Redis,代码就是普通的 HttpSession,和 Spring Session 没有任何关系!

唯一和 Spring Session 相关的,可能就是我在一开始引入了 Spring Session 的依赖吧!

如果大家没有在 SSM 架构中用过 Spring Session ,可能不太好理解我们在 Spring Boot 中使用 Spring Session 有多么方便,因为在 SSM 架构中,Spring Session 的使用要配置三个地方 ,一个是 web.xml 配置代理过滤器,然后在 Spring 容器中配置 Redis,最后再配置 Spring Session,步骤还是有些繁琐的,而 Spring Boot 中直接帮我们省去了这些繁琐的步骤!不用再去配置 Spring Session。

SpringBoot构建RESTful

RESTful ,到现在相信已经没人不知道这个东西了吧!关于 RESTful 的概念,我这里就不做过多介绍了,传统的 Struts 对 RESTful 支持不够友好 ,但是 SpringMVC 对于 RESTful 提供了很好的支持,常见的相关注解有:

@RestController
@GetMapping
@PutMapping
@PostMapping
@DeleteMapping
@ResponseBody
...

这些注解都是和 RESTful 相关的,在移动互联网中,RESTful 得到了非常广泛的使用。RESTful 这个概念提出来很早,但是以前没有移动互联网时,我们做的大部分应用都是前后端不分的,在这种架构的应用中,数据基本上都是在后端渲染好返回给前端展示的,此时 RESTful 在 Web 应用中基本就没用武之地,移动互联网的兴起,让我们一套后台对应多个前端项目,因此前后端分离,RESTful 顺利走上前台。

Spring Boot 继承自 Spring + SpringMVC, SpringMVC 中对于 RESTful 支持的特性在 Spring Boot 中全盘接收,同时,结合 Jpa 和 自动化配置,对于 RESTful 还提供了更多的支持,使得开发者几乎不需要写代码(很少几行),就能快速实现一个 RESTful 风格的增删改查

1. Spring Data JPA构建

1.1 创建工程

首先创建一个 Spring Boot 工程,引入 WebJpaMySQLRest Repositories 依赖:

img

创建完成后,还需要锁定 MySQL 驱动的版本以及加入 Druid 数据库连接池,完整依赖如下:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-rest</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>1.1.10</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
        <version>5.1.27</version>
    </dependency>
</dependencies>

1.2 配置数据库

主要配置两个,一个是数据库,另一个是 Jpa:

spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mysql:///test01
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.database-platform=mysql
spring.jpa.database=mysql

这里的配置,和 Jpa 中的基本一致。

前面五行配置了数据库的基本信息,包括数据库连接池、数据库用户名、数据库密码、数据库连接地址以及数据库驱动名称。

接下来的五行配置了 JPA 的基本信息,分别表示生成 SQL 的方言、打印出生成的 SQL 、每次启动项目时根据实际情况选择是否更新表、数据库平台是 MySQL。

1.3 构建实体类

@Entity(name = "t_book")
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = "book_name")
    private String name;
    private String author;
    //省略 getter/setter
}
public interface BookRepository extends JpaRepository<Book,Long> {
}

这里一个是配置了一个实体类 Book,另一个则是配置了一个 BookRepository ,项目启动成功后,框架会根据 Book 类的定义,在数据库中自动创建相应的表,BookRepository 接口则是继承自 JpaRepository ,JpaRepository 中自带了一些基本的增删改查方法。

一个 RESTful 风格的增删改查应用就有了,这就是 Spring Boot 的魅力

1.4 测试

此时,我们就可以启动项目进行测试了,使用 POSTMAN 来测试(大家也可以自行选择趁手的 HTTP 请求工具)

此时我们的项目已经默认具备了一些接口,我们分别来看:

根据 id 查询接口

这个接口表示根据 id 查询某一本书:

img

分页查询

这是一个批量查询接口,默认请求路径是类名首字母小写,并且再加一个 s 后缀。这个接口实际上是一个分页查询接口,没有传参数,表示查询第一页,每页 20 条数据。

img

查询结果中,除了该有的数据之外,也包含了分页数据:

img

分页数据中:

  1. size 表示每页查询记录数
  2. totalElements 表示总记录数
  3. totalPages 表示总页数
  4. number 表示当前页数,从0开始计

如果要分页或者排序查询,可以使用 _links 中的链接。http://127.0.0.1:8080/books?page=1&size=3&sort=id,desc

img

添加

也可以添加数据,添加是 POST 请求,数据通过 JSON 的形式传递,如下:

img

添加成功之后,默认会返回添加成功的数据。

修改

修改接口默认也是存在的,数据修改请求是一个 PUT 请求,修改的参数也是通过 JSON 的形式传递:

img

默认情况下,修改成功后,会返回修改成功的数据。

删除

当然也可以通过 DELETE 请求根据 id 删除数据:

img

删除成功后,是没有返回值的。

不需要几行代码,一个基本的增删改查就有了。

这些都是默认的配置,这些默认的配置实际上都是在 JpaRepository 的基础上实现的,实际项目中,我们还可以对这些功能进行定制。

1.5 查询定制

最广泛的定制,就是查询,因为增删改操作的变化不像查询这么丰富。对于查询的定制,非常容易,只需要提供相关的方法即可。例如根据作者查询书籍:

public interface BookRepository extends JpaRepository<Book,Long> {
    List<Book> findBookByAuthorContaining(@Param("author") String author);
}

注意,方法的定义,参数要有 @Param 注解。

定制完成后,重启项目,此时就多了一个查询接口,开发者可以通过 http://localhost:8080/books/search 来查看和 book 相关的自定义接口都有哪些:

img

查询结果表示,只有一个自定义接口,接口名就是方法名,而且查询结果还给出了接口调用的示例。我们来尝试调用一下自己定义的查询接口:

img

开发者可以根据实际情况,在 BookRepository 中定义任意多个查询方法,查询方法的定义规则和 Jpa 中一模一样,但是,这样有一个缺陷,就是 Jpa 中方法名太长,因此,如果不想使用方法名作为接口名,则可以自定义接口名

public interface BookRepository extends JpaRepository<Book, Long> {
    @RestResource(rel = "byauthor",path = "byauthor")
    List<Book> findBookByAuthorContaining(@Param("author") String author);
}

@RestResource 注解中,两个参数的含义:

  • rel 表示接口查询中,这个方法的 key
  • path 表示请求路径

这样定义完成后,表示接口名为 byauthor ,重启项目,继续查询接口:

img

除了 relpath 两个属性之外,@RestResource 中还有一个属性,exported 表示是否暴露接口,默认为 true,表示暴露接口,即方法可以在前端调用,如果仅仅只是想定义一个方法,不需要在前端调用这个方法,可以设置 exported 属性为 false

如果不想暴露官方定义好的方法,例如根据 id 删除数据,只需要在自定义接口中重写该方法,然后在该方法上加 @RestResource 注解并且配置相关属性即可。

public interface BookRepository extends JpaRepository<Book, Long> {
    @RestResource(rel = "byauthor",path = "byauthor")
    List<Book> findBookByAuthorContaining(@Param("author") String author);
    @Override
    @RestResource(exported = false)
    void deleteById(Long aLong);
}

另外生成的 JSON 字符串中的集合名和单个 item 的名字都是可以自定义的:

@RepositoryRestResource(collectionResourceRel = "bs",itemResourceRel = "b",path = "bs")
public interface BookRepository extends JpaRepository<Book, Long> {
    @RestResource(rel = "byauthor",path = "byauthor")
    List<Book> findBookByAuthorContaining(@Param("author") String author);
    @Override
    @RestResource(exported = false)
    void deleteById(Long aLong);
}

path 属性表示请求路径,请求路径默认是类名首字母小写+s,可以在这里自己重新定义。

img

1.6 其他配置

最后,也可以在 application.properties 中配置 REST 基本参数:

spring.data.rest.base-path=/api
spring.data.rest.sort-param-name=sort
spring.data.rest.page-param-name=page
spring.data.rest.limit-param-name=size
spring.data.rest.max-page-size=20
spring.data.rest.default-page-size=0
spring.data.rest.return-body-on-update=true
spring.data.rest.return-body-on-create=true

配置含义,从上往下,依次是:

  • 给所有的接口添加统一的前缀
  • 配置排序参数的 key ,默认是 sort
  • 配置分页查询时页码的 key,默认是 page
  • 配置分页查询时每页查询页数的 key,默认是size
  • 配置每页最大查询记录数,默认是 20 条
  • 分页查询时默认的页码
  • 更新成功时是否返回更新记录
  • 添加成功时是否返回添加记录

SpringBoot开发者工具

1. Devtools

image-20210524151205975

Spring Boot 中的热部署相信大家都用过吧,只需要添加 spring-boot-devtools 依赖就可以轻松实现热部署。Spring Boot 中热部署最最关键的原理就是两个不同的 classloader:

  • base classloader
  • restart classloader

其中 base classloader 用来加载那些不会变化的类,例如各种第三方依赖,而 restart classloader 则用来加载那些会发生变化的类,例如你自己写的代码。Spring Boot 中热部署的原理就是当代码发生变化时,base classloader 不变,而 restart classloader 则会被废弃,被另一个新的 restart classloader 代替。在整个过程中,因为只重新加载了变化的类,所以启动速度要被重启快

引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
</dependency>

在IDEA中开启

image-20210524154546455

找到

image-20210524154656023

Registry

image-20210524154820641

即可开启自动重启

配置文件

#关闭自动重启
spring.devtools.restart.enabled=false
#触发重启的文件,放置在resources目录下
spring.devtools.restart.trigger-file=.reloadtrigger

image-20210524153804327

添加后这个文件下,每次去修改.reloadtrigger文件然后去刷新才会重启,这里的重启还是不全部重启的刷新

如果有个多模块的项目,在当前目录下创建spring-boot-devtools.properties文件

这里面的配置都让所有使用Devtools模块的项目都生效

但是有另外一个问题,就是静态资源文件!使用 devtools ,默认情况下当静态资源发生变化时,并不会触发项目重启。虽然我们可以通过配置解决这一问题,但是没有必要!因为静态资源文件发生变化后不需要编译,按理说保存后刷新下就可以访问到了,所以静态资源不会被Devtools监视到,静态资源不需要重启也能直接同步到target目录下

image-20210524151746545

LiveReload

devtools 中默认嵌入了 LiveReload 服务器,利用 LiveReload 可以实现静态文件的热部署,LiveReload 可以在资源发生变化时自动触发浏览器更新,LiveReload 支持 Chrome、Firefox 以及 Safari 。以 Chrome 为例,在 Chrome 应用商店搜索 LiveReload ,结果如下图:

img

将第一个搜索结果添加到 Chrome 中,添加成功后,在 Chrome 右上角有一个 LiveReload 图标

img

在浏览器中打开项目的页面,然后点击浏览器右上角的 LiveReload 按钮,打开 LiveReload 连接。

注意:

LiveReload 是和浏览器选项卡绑定在一起的,在哪个选项卡中打开了 LiveReload,就在哪个选项卡中访问页面,这样才有效果

此时随便在 resources/static 目录下添加一个静态 html 页面,然后启动 Spring Boot 项目,在打开了 LiveReload 的选项卡中访问 html 页面

访问成功后,我们再去手动修改 html 页面代码,修改成功后,回到浏览器,不用做任何操作,就会发现浏览器自动刷新了,页面已经更新了。

整个过程中,我的 Spring Boot 项目并没有重启。

如果开发者安装并且启动了 LiveReload 插件,同时也添加了 devtools 依赖,但是却并不想当静态页面发生变化时浏览器自动刷新,那么可以在 application.properties 中添加如下代码进行配置:

spring.devtools.livereload.enabled=false

建议开发者使用 LiveReload 策略而不是项目重启策略来实现静态资源的动态加载,因为项目重启所耗费时间一般来说要超过使用LiveReload 所耗费的时间

2. Mock单元测试

针对Controller的接口简单测试,这里不使用Postman,使用Java代码来完成

实体类

public class User {
    private Long id;
    private String username;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }
}

Controller

@RestController
public class UserController {
    @GetMapping("/user/{id}")
    public User getUserById(@PathVariable Long id) {
        User user = new User();
        user.setId(id);
        user.setUsername("javaboy");
        return user;
    }
}

测试类

/**
 * webEnvironment:指定 web 应用环境:
 *
 * - MOCK
 * - RANDOM_PORT (对事务不回滚)
 * - DEFINED_PORT (对事务不回滚)
 * - NONE
 *
 * classes:指定应用启动类
 */
@SpringBootTest()
class TestApplicationTests {

    @Autowired
    WebApplicationContext webApplicationContext;
    MockMvc mockMvc;
    @BeforeEach //相当于Before
    void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
   //或者,但不推荐  mockMvc = MockMvcBuilders.standaloneSetup(new UserController()).build();
    }
    @Test
    void contextLoads() throws Exception {
        //GET请求
  			mockMvc
  .perform(MockMvcRequestBuilders.get("/user/99").accept(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().string("{\"id\":99,\"username\":\"javaboy\"}"))
                .andDo(MockMvcResultHandlers.print());
    }
    
}

对Service的测试

@Service
public class UserService {
    @Autowired
    UserDao userDao;

    public User getUserById(Long id) {
        return userDao.getUserById(id);
    }
}

DAO

@Repository
public class UserDao {

    public User getUserById(Long id) {
        User user = new User();
        user.setId(id);
        user.setUsername("111");
        return user;
    }
}

测试类

@SpringBootTest()
class TestApplicationTests {    
//不涉及数据库的注入,如果用AutoWired会改变数据库
    @MockBean
    UserDao userDao;
    @Autowired
    UserService userService;
    @Test
    void test1() {
      	//仅供测试的数据
        User user = new User();
        user.setId(99L);
        user.setUsername("javaboy");
        Mockito.when(userDao.getUserById(99L)).thenReturn(user);

        User u = userService.getUserById(99L);
        Assertions.assertEquals(99L,u.getId());
        Assertions.assertEquals("javaboy", u.getUsername());
    }
}

SpringBoot整合缓存

1. Spring Cache + Redis

经过Spring Boot的整合封装与自动化配置,在Spring Boot中整合Redis已经变得非常容易了,开发者只需要引入Spring Data Redis依赖,然后简单配下redis的基本信息,系统就会提供一个RedisTemplate供开发者使用。Spring3.1中开始引入了令人激动的Cache,在Spring Boot中,可以非常方便的使用Redis来作为Cache的实现,进而实现数据的缓存。

1.1 工程创建

首先创建一个Spring Boot工程,注意创建的时候需要引入三个依赖,web、cache以及redis,如下图:

img

对应的依赖内容如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

1.2 基本配置

工程创建好之后,首先需要简单配置一下Redis,Redis的基本信息,另外,这里要用到Cache,因此还需要稍微配置一下Cache,如下:

spring.redis.port=6380
spring.redis.host=192.168.66.128

spring.cache.cache-names=c1

简单起见,这里我只是配置了Redis的端口和地址,然后给缓存取了一个名字,这个名字在后文会用到。

另外,还需要在配置类上添加如下代码,表示开启缓存:

@SpringBootApplication
@EnableCaching
public class RediscacheApplication {

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

}

完成了这些配置之后,Spring Boot就会自动帮我们在后台配置一个RedisCacheManager,相关的配置是在org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration类中完成的。部分源码如下:

@Configuration
@ConditionalOnClass(RedisConnectionFactory.class)
@AutoConfigureAfter(RedisAutoConfiguration.class)
@ConditionalOnBean(RedisConnectionFactory.class)
@ConditionalOnMissingBean(CacheManager.class)
@Conditional(CacheCondition.class)
class RedisCacheConfiguration {

	@Bean
	public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory,
			ResourceLoader resourceLoader) {
		RedisCacheManagerBuilder builder = RedisCacheManager
				.builder(redisConnectionFactory)
				.cacheDefaults(determineConfiguration(resourceLoader.getClassLoader()));
		List<String> cacheNames = this.cacheProperties.getCacheNames();
		if (!cacheNames.isEmpty()) {
			builder.initialCacheNames(new LinkedHashSet<>(cacheNames));
		}
		return this.customizerInvoker.customize(builder.build());
	}
}

看类上的注解,发现在万事俱备的情况下,系统会自动提供一个RedisCacheManager的Bean,这个RedisCacheManager间接实现了Spring中的Cache接口,有了这个Bean,我们就可以直接使用Spring中的缓存注解和接口了,而缓存数据则会被自动存储到Redis上。在单机的Redis中,这个Bean系统会自动提供,如果是Redis集群,这个Bean需要开发者来提供。

1.3 缓存使用

这里主要向小伙伴们介绍缓存中几个核心的注解使用。

@CacheConfig

这个注解在类上使用,用来描述该类中所有方法使用的缓存名称,当然也可以不使用该注解,直接在具体的缓存注解上配置名称,示例代码如下:

@Service
@CacheConfig(cacheNames = "c1")
public class UserService {
}

@Cacheable

这个注解一般加在查询方法上,表示将一个方法的返回值缓存起来,默认情况下,缓存的key就是方法的参数,缓存的value就是方法的返回值。示例代码如下:

@Cacheable(key = "#id")
public User getUserById(Integer id,String username) {
    System.out.println("getUserById");
    return getUserFromDBById(id);
}

当有多个参数时,默认就使用多个参数来做key,如果只需要其中某一个参数做key,则可以在@Cacheable注解中,通过key属性来指定key,如上代码就表示只使用id作为缓存的key,如果对key有复杂的要求,可以自定义keyGenerator。当然,Spring Cache中提供了root对象,即#root.可以在不定义keyGenerator的情况下实现一些复杂的效果:

img

也可以自定义缓存的格式

@Component
public class MyKeyGenerator implements KeyGenerator {
    @Override
    public Object generate(Object target, Method method, Object... params) {
        String s = target.toString() + ":" + method.getName() + ":" + Arrays.toString(params);
        return s;
    }
}

@Service
@CacheConfig(cacheNames = "c1")
public class UserService { 
@Cacheable(keyGenerator = "myKeyGenerator")
    public User getUserById2(Long id,String username) {
        System.out.println("getUserById2:" + id);
        User user = new User();
        user.setId(id);
        user.setUsername(username);
        return user;
    }
  
}

@CachePut

这个注解一般加在更新方法上,当数据库中的数据更新后,缓存中的数据也要跟着更新,使用该注解,可以将方法的返回值自动更新到已经存在的key上,示例代码如下:

//如果缓存不存在,则进行缓存,否则进行更新
@CachePut(key = "#user.id")
public User updateUserById(User user) {
    return user;
}

@CacheEvict

这个注解一般加在删除方法上,当数据库中的数据删除后,相关的缓存数据也要自动清除,该注解在使用的时候也可以配置按照某种条件删除(condition属性)或者或者配置清除所有缓存(allEntries属性),示例代码如下:

@CacheEvict()
public void deleteUserById(Integer id) {
    //在这里执行删除操作, 删除是去数据库中删除
}

1.4 总结

在Spring Boot中,使用Redis缓存,既可以使用RedisTemplate自己来实现,也可以使用使用这种方式,这种方式是Spring Cache提供的统一接口,实现既可以是Redis,也可以是Ehcache或者其他支持这种规范的缓存框架。从这个角度来说,Spring Cache和Redis、Ehcache的关系就像JDBC与各种数据库驱动的关系。

2. Spring Cache + Ehcache

用惯了 Redis ,很多人已经忘记了还有另一个缓存方案 Ehcache ,是的,在 Redis 一统江湖的时代,Ehcache 渐渐有点没落了,不过,我们还是有必要了解下 Ehcache ,在有的场景下,我们还是会用到 Ehcache。

Ehcache 也是 Java 领域比较优秀的缓存方案之一,Ehcache 这个缓存的名字很有意思,正着念反着念,都是 Ehcache,Spring Boot 中对此也提供了很好的支持,这个支持主要是通过 Spring Cache 来实现的。

Spring Cache 可以整合 Redis,当然也可以整合 Ehcache,两种缓存方案的整合还是比较相似,主要是配置的差异,具体的用法是一模一样的,就类似于 JDBC 和 数据库驱动的关系一样。前面配置完成后,后面具体使用的 API 都是一样的。

和 Spring Cache + Redis 相比,Spring Cache + Ehcache 主要是配置有所差异,具体的用法是一模一样的。我们来看下使用步骤。

2.1 项目创建

首先,来创建一个 Spring Boot 项目,引入 Cache 依赖:

img

工程创建完成后,引入 Ehcache 的依赖,Ehcache 目前有两个版本:

img

这里采用第二个,在 pom.xml 文件中,引入 Ehcache 依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>net.sf.ehcache</groupId>
        <artifactId>ehcache</artifactId>
        <version>2.10.6</version>
    </dependency>
</dependencies>

2.2 添加 Ehcache 配置

在 resources 目录下,添加 ehcache 的配置文件 ehcache.xml ,文件内容如下:

<ehcache>
    <diskStore path="java.io.tmpdir/shiro-spring-sample"/>
    <defaultCache
            maxElementsInMemory="10000"
            eternal="false"
            timeToIdleSeconds="120"
            timeToLiveSeconds="120"
            overflowToDisk="false"
            diskPersistent="false"
            diskExpiryThreadIntervalSeconds="120"
    />
    <cache name="user"
            maxElementsInMemory="10000"
            eternal="true"
            overflowToDisk="true"
            diskPersistent="true"
            diskExpiryThreadIntervalSeconds="600"/>
</ehcache>

配置含义:

  • name:缓存名称。
  • maxElementsInMemory:缓存最大个数。
  • eternal:对象是否永久有效,一但设置了,timeout将不起作用。
  • timeToIdleSeconds:设置对象在失效前的允许闲置时间(单位:秒)。仅当eternal=false对象不是永久有效时使用,可选属性,默认值是0,也就是可闲置时间无穷大。
  • timeToLiveSeconds:设置对象在失效前允许存活时间(单位:秒)。最大时间介于创建时间和失效时间之间。仅当eternal=false对象不是永久有效时使用,默认是0.,也就是对象存活时间无穷大。
  • overflowToDisk:当内存中对象数量达到maxElementsInMemory时,Ehcache将会对象写到磁盘中。
  • diskSpoolBufferSizeMB:这个参数设置DiskStore(磁盘缓存)的缓存区大小。默认是30MB。每个Cache都应该有自己的一个缓冲区。
  • maxElementsOnDisk:硬盘最大缓存个数。
  • diskPersistent:是否缓存虚拟机重启期数据。
  • diskExpiryThreadIntervalSeconds:磁盘失效线程运行时间间隔,默认是120秒。
  • memoryStoreEvictionPolicy:当达到maxElementsInMemory限制时,Ehcache将会根据指定的策略去清理内存。默认策略是LRU(最近最少使用)。你可以设置为FIFO(先进先出)或是LFU(较少使用)。
  • clearOnFlush:内存数量最大时是否清除。
  • diskStore 则表示临时缓存的硬盘目录。

注意

默认情况下,这个文件名是固定的,必须叫 ehcache.xml ,如果一定要换一个名字,那么需要在 application.properties 中明确指定配置文件名,配置方式如下:

spring.cache.ehcache.config=classpath:aaa.xml

2.3 开启缓存

开启缓存的方式,也和 Redis 中一样,如下添加 @EnableCaching 依赖即可:

@SpringBootApplication
@EnableCaching
public class EhcacheApplication {
    public static void main(String[] args) {
        SpringApplication.run(EhcacheApplication.class, args);
    }
}

其实到这一步,Ehcache 就算配置完成了,接下来的用法,和Redis的就一样了

2.4 使用缓存

这里主要向小伙伴们介绍缓存中几个核心的注解使用。

@CacheConfig

这个注解在类上使用,用来描述该类中所有方法使用的缓存名称,当然也可以不使用该注解,直接在具体的缓存注解上配置名称,示例代码如下:

@Service
@CacheConfig(cacheNames = "user")
public class UserService {
}

@Cacheable

这个注解一般加在查询方法上,表示将一个方法的返回值缓存起来,默认情况下,缓存的 key 就是方法的参数,缓存的 value 就是方法的返回值。示例代码如下:

@Cacheable(key = "#id")
public User getUserById(Integer id,String username) {
    System.out.println("getUserById");
    return getUserFromDBById(id);
}

当有多个参数时,默认就使用多个参数来做 key ,如果只需要其中某一个参数做 key ,则可以在 @Cacheable 注解中,通过 key 属性来指定 key ,如上代码就表示只使用 id 作为缓存的 key ,如果对 key 有复杂的要求,可以自定义 keyGenerator 。当然,Spring Cache 中提供了root对象,可以在不定义 keyGenerator 的情况下实现一些复杂的效果,root 对象有如下属性:

img

也可以通过 keyGenerator 自定义 key ,方式如下:

@Component
public class MyKeyGenerator implements KeyGenerator {
    @Override
    public Object generate(Object target, Method method, Object... params) {
        return method.getName()+Arrays.toString(params);
    }
}

然后在方法上使用该 keyGenerator :

@Cacheable(keyGenerator = "myKeyGenerator")
public User getUserById(Long id) {
    User user = new User();
    user.setId(id);
    user.setUsername("lisi");
    System.out.println(user);
    return user;
}

@CachePut

这个注解一般加在更新方法上,当数据库中的数据更新后,缓存中的数据也要跟着更新,使用该注解,可以将方法的返回值自动更新到已经存在的 key 上,示例代码如下:

@CachePut(key = "#user.id")
public User updateUserById(User user) {
    return user;
}

@CacheEvict

这个注解一般加在删除方法上,当数据库中的数据删除后,相关的缓存数据也要自动清除,该注解在使用的时候也可以配置按照某种条件删除( condition 属性)或者或者配置清除所有缓存( allEntries 属性),示例代码如下:

@CacheEvict()
public void deleteUserById(Integer id) {
    //在这里执行删除操作, 删除是去数据库中删除
}

SpringBoot安全管理

1. Spring Security

Spring Security 是 Spring 家族中的一个安全管理框架,实际上,在 Spring Boot 出现之前,Spring Security 就已经发展了多年了,但是使用的并不多,安全管理这个领域,一直是 Shiro 的天下。

相对于 Shiro,在 SSM/SSH 中整合 Spring Security 都是比较麻烦的操作,所以,Spring Security 虽然功能比 Shiro 强大,但是使用反而没有 Shiro 多(Shiro 虽然功能没有 Spring Security 多,但是对于大部分项目而言,Shiro 也够用了)。

自从有了 Spring Boot 之后,Spring Boot 对于 Spring Security 提供了 自动化配置方案,可以零配置使用 Spring Security。

因此,一般来说,常见的安全管理技术栈的组合是这样的:

  • SSM + Shiro
  • Spring Boot/Spring Cloud + Spring Security

注意,这只是一个推荐的组合而已,如果单纯从技术上来说,无论怎么组合,都是可以运行的。

1.1 项目创建

在 Spring Boot 中使用 Spring Security 非常容易,引入依赖即可:

img

pom.xml 中的 Spring Security 依赖:

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

只要加入依赖,项目的所有接口都会被自动保护起来。

1.2 初次体验

我们创建一个 HelloController:

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}

访问 /hello ,需要登录之后才能访问。

img

当用户从浏览器发送请求访问 /hello 接口时,服务端会返回 302 响应码,让客户端重定向到 /login 页面,用户在 /login 页面登录,登陆成功之后,就会自动跳转到 /hello 接口

另外,也可以使用 POSTMAN 来发送请求,使用 POSTMAN 发送请求时,可以将用户信息放在请求头中(这样可以避免重定向到登录页面):

img

通过以上两种不同的登录方式,可以看出,Spring Security 支持两种不同的认证方式:

  • 可以通过 form 表单来认证
  • 可以通过 HttpBasic 来认证

1.3 用户名配置

默认情况下,登录的用户名是 user ,密码则是项目启动时随机生成的字符串,可以从启动的控制台日志中看到默认密码:

img

这个随机生成的密码,每次启动时都会变。对登录的用户名/密码进行配置,有三种不同的方式:

  • 在 application.properties 中进行配置
  • 通过 Java 代码配置在内存中
  • 通过 Java 从数据库中加载

前两种比较简单,第三种代码量略大,本文就先来看看前两种,第三种后面再单独写文章介绍,也可以参考微人事项目

配置文件配置用户名/密码

可以直接在 application.properties 文件中配置用户的基本信息:

spring.security.user.name=javaboy
spring.security.user.password=123

配置完成后,重启项目,就可以使用这里配置的用户名/密码登录了

Java 配置用户名/密码

也可以在 Java 代码中配置用户名密码,首先需要我们创建一个 Spring Security 的配置类,集成自 WebSecurityConfigurerAdapter 类,如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //下面这两行配置表示在内存中配置了两个用户
        auth.inMemoryAuthentication()
                .withUser("javaboy").roles("admin").password("$2a$10$OR3VSksVAmCzc.7WeaRPR.t0wyCsIj24k0Bne8iKWV1o.V9wsP8Xe")
                .and()
          .withUser("lisi").roles("user").password("$2a$10$p1H8iWa8I4.CA.7Z8bwLjes91ZpY.rYREGHQEInNtAp4NzL6PLKxi");
    }
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

这里我们在 configure 方法中配置了两个用户,用户的密码都是加密之后的字符串(明文是 123),从 Spring5 开始,强制要求密码要加密,如果非不想加密,可以使用一个过期的 PasswordEncoder 的实例 NoOpPasswordEncoder,但是不建议这么做,毕竟不安全。

1.4 加密方案

密码加密我们一般会用到散列函数,又称散列算法、哈希函数,这是一种从任何数据中创建数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来,然后将数据打乱混合,重新创建一个散列值。散列值通常用一个短的随机字母和数字组成的字符串来代表。好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理中,不抑制冲突来区别数据,会使得数据库记录更难找到。我们常用的散列函数有 MD5 消息摘要算法、安全散列算法(Secure Hash Algorithm)。

但是仅仅使用散列函数还不够,为了增加密码的安全性,一般在密码加密过程中还需要加盐,所谓的盐可以是一个随机数也可以是用户名,加盐之后,即使密码明文相同的用户生成的密码密文也不相同,这可以极大的提高密码的安全性。但是传统的加盐方式需要在数据库中有专门的字段来记录盐值,这个字段可能是用户名字段(因为用户名唯一),也可能是一个专门记录盐值的字段,这样的配置比较繁琐。

Spring Security 提供了多种密码加密方案,官方推荐使用 BCryptPasswordEncoder,BCryptPasswordEncoder 使用 BCrypt 强哈希函数,开发者在使用时可以选择提供 strength 和 SecureRandom 实例。strength 越大,密钥的迭代次数越多,密钥迭代次数为 2^strength。strength 取值在 4~31 之间,默认为 10。

Spring Security 中提供了 BCryptPasswordEncoder 密码编码工具,可以非常方便的实现密码的加密加盐,相同明文加密出来的结果总是不同,这样就不需要用户去额外保存的字段了,这一点比 Shiro 要方便很多,而 BCryptPasswordEncoder 就是 PasswordEncoder 接口的实现类,只需要提供 BCryptPasswordEncoder 这个 Bean 的实例即可,如下:

@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(10);
}

创建 BCryptPasswordEncoder 时传入的参数 10 就是 strength,即密钥的迭代次数(也可以不配置,默认为 10)。同时,配置的内存用户的密码也不再是 123 了,如下:

auth.inMemoryAuthentication()
.withUser("admin")
.password("$2a$10$RMuFXGQ5AtH4wOvkUqyvuecpqUSeoxZYqilXzbz50dceRsga.WYiq")
.roles("ADMIN", "USER")
.and()
.withUser("sang")
.password("$2a$10$eUHbAOMq4bpxTvOVz33LIehLe3fu6NwqC9tdOcxJXEhyZ4simqXTC")
.roles("USER");

这里的密码就是使用 BCryptPasswordEncoder 加密后的密码,虽然 admin 和 sang 加密后的密码不一样,但是明文都是 123。配置完成后,使用 admin/123 或者 sang/123 就可以实现登录。

本案例使用了配置在内存中的用户,一般情况下,用户信息是存储在数据库中的,因此需要在用户注册时对密码进行加密处理,如下:

@Service
public class RegService {
    public int reg(String username, String password) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
        String encodePasswod = encoder.encode(password);
        return saveToDb(username, encodePasswod);
    }
}

用户将密码从前端传来之后,通过调用 BCryptPasswordEncoder 实例中的 encode 方法对密码进行加密处理,加密完成后将密文存入数据库。

1.5 登录配置

对于登录接口,登录成功后的响应,登录失败后的响应,我们都可以在 WebSecurityConfigurerAdapter 的实现类中进行配置。例如下面这样:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Autowired
    VerifyCodeFilter verifyCodeFilter;
    //http配置
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(verifyCodeFilter, UsernamePasswordAuthenticationFilter.class);
        http
        .authorizeRequests()//开启登录配置
        .antMatchers("/hello").hasRole("admin")//表示访问 /hello 这个接口,需要具备 admin 这个角色
        .anyRequest().authenticated()//表示剩余的其他接口,登录之后就能访问
        .and()
        .formLogin()
        //定义登录页面,未登录时,访问一个需要登录之后才能访问的接口,会自动跳转到该页面
        .loginPage("/login_p")
        //登录处理接口
        .loginProcessingUrl("/doLogin")
        //定义登录时,用户名的 key,默认为 username
        .usernameParameter("uname")
        //定义登录时,用户密码的 key,默认为 password
        .passwordParameter("passwd")
          
        //登录成功的处理器
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        Map<String, Object> map = new HashMap<>();
                        map.put("status", 200);
                        map.put("msg", authentication.getPrincipal());
                        out.write(new ObjectMapper().writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })
          
          //登录失败的处理器
               .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp, AuthenticationException e) throws IOException, ServletException {
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        Map<String, Object> map = new HashMap<>();
                        map.put("status", 401);
                        if (e instanceof LockedException) {
                            map.put("msg", "账户被锁定,登录失败!");
                        } else if (e instanceof BadCredentialsException) {
                            map.put("msg", "用户名或密码输入错误,登录失败!");
                        } else if (e instanceof DisabledException) {
                            map.put("msg", "账户被禁用,登录失败!");
                        } else if (e instanceof AccountExpiredException) {
                            map.put("msg", "账户过期,登录失败!");
                        } else if (e instanceof CredentialsExpiredException) {
                            map.put("msg", "密码过期,登录失败!");
                        } else {
                            map.put("msg", "登录失败!");
                        }
                        out.write(new ObjectMapper().writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })
            .permitAll()//和表单登录相关的接口统统都直接通过
            .and()
            .logout()
            .logoutUrl("/logout")
          //注销成功的处理器
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override
                    public void onLogoutSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        Map<String, Object> map = new HashMap<>();
                        map.put("status", 200);
                        map.put("msg", "注销登录成功!");
                        out.write(new ObjectMapper().writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })
            .permitAll()
            .and()
            .httpBasic()
            .and()
            .csrf().disable();
    }
}

我们可以在 successHandler 方法中,配置登录成功的回调,如果是前后端分离开发的话,登录成功后返回 JSON 即可,同理,failureHandler 方法中配置登录失败的回调,logoutSuccessHandler 中则配置注销成功的回调。

多个Http的配置

@Configuration
//不需要继承
public class MultiHttpSecurityConfig {

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
  
		//公用
    @Autowired
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("javaboy").password("$2a$10$G3kVAJHvmRrr6sOj.j4xpO2Dsxl5EG8rHycPHFWyi9UMIhtdSH15u").roles("admin")
                .and()
                .withUser("江南一点雨").password("$2a$10$kWjG2GxWhm/2tN2ZBpi7bexXjUneIKFxIAaMYJzY7WcziZLCD4PZS").roles("user");
    }

    @Configuration
    @Order(1)
    public static class AdminSecurityConfig extends WebSecurityConfigurerAdapter{
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.antMatcher("/admin/**").authorizeRequests().anyRequest().hasAnyRole("admin");
        }
    }

    @Configuration
    public static class OtherSecurityConfig extends WebSecurityConfigurerAdapter{
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests().anyRequest().authenticated()
                    .and()
                    .formLogin()
                    .loginProcessingUrl("/doLogin")
                    .permitAll()
                    .and()
                    .csrf().disable();
        }
    }
}

表达式控制方法权限

方法的安全默认是没有开启的,如果在Config类上加上注解@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)就可以开启方法的安全

这个配置开启了三个注解,分别是:

  • @PreAuthorize:方法执行前进行权限检查
  • @PostAuthorize:方法执行后进行权限检查
  • @Secured:类似于 @PreAuthorize

这三个结合 SpEL 之后,用法非常灵活,这里和大家稍微分享几个 Demo。

@Service
public class HelloService {
    @PreAuthorize("principal.username.equals('javaboy')")
    public String hello() {
        return "hello";
    }

    @PreAuthorize("hasRole('admin')")
    public String admin() {
        return "admin";
    }

    @Secured({"ROLE_user"})
    public String user() {
        return "user";
    }

    @PreAuthorize("#age>98")
    public String getAge(Integer age) {
        return String.valueOf(age);
    }
}
  • 第一个 hello 方法,注解的约束是,只有当前登录用户名为 javaboy 的用户才可以访问该方法。
  • 第二个 admin 方法,表示访问该方法的用户必须具备 admin 角色。
  • 第三个 user 方法,表示方法该方法的用户必须具备 user 角色,但是注意 user 角色需要加上 ROLE_ 前缀。
  • 第四个 getAge 方法,表示访问该方法的 age 参数必须大于 98,否则请求不予通过。

可以看到,这里的表达式还是非常丰富,如果想引用方法的参数,前面加上一个 # 即可,既可以引用基本类型的参数,也可以引用对象参数。

过滤注解

Spring Security 中还有两个过滤函数 @PreFilter 和 @PostFilter,可以根据给出的条件,自动移除集合中的元素。

@PostFilter("filterObject.lastIndexOf('2')!=-1")
public List<String> getAllUser() {
    List<String> users = new ArrayList<>();
    for (int i = 0; i < 10; i++) {
        users.add("javaboy:" + i);
    }
    return users;
}
@PreFilter(filterTarget = "ages",value = "filterObject%2==0")
public void getAllAge(List<Integer> ages,List<String> users) {
    System.out.println("ages = " + ages);
    System.out.println("users = " + users);
}
  • 在 getAllUser 方法中,对集合进行过滤,只返回后缀为 2 的元素,filterObject 表示要过滤的元素对象。
  • 在 getAllAge 方法中,由于有两个集合,因此使用 filterTarget 指定过滤对象。

忽略拦截

如果某一个请求地址不需要拦截的话,有两种方式实现:

  • 设置该地址匿名访问
  • 直接过滤掉该地址,即该地址不走 Spring Security 过滤器链

推荐使用第二种方案,配置如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/vercode");
    }
}

1.7 自定义表单登录

继续完善前面的 SecurityConfig 类,继续重写它的 configure(WebSecurity web)configure(HttpSecurity http) 方法,如下:

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers("/js/**", "/css/**","/images/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .loginPage("/login.html")
            .permitAll()
            .and()
            .csrf().disable();
}
  • web.ignoring() 用来配置忽略掉的 URL 地址,一般对于静态文件,我们可以采用此操作。
  • 如果我们使用 XML 来配置 Spring Security ,里边会有一个重要的标签 <http>,HttpSecurity 提供的配置方法 都对应了该标签。
  • authorizeRequests 对应了 <intercept-url>
  • formLogin 对应了 <formlogin>
  • and 方法表示结束当前标签,上下文回到HttpSecurity,开启新一轮的配置。
  • permitAll 表示登录相关的页面/接口不要被拦截。
  • 最后记得关闭 csrf ,关于 csrf 问题我到后面专门和大家说

当我们定义了登录页面为 /login.html 的时候,Spring Security 也会帮我们自动注册一个 /login.html 的接口,这个接口是 POST 请求,用来处理登录逻辑。

我们将登录页面的相关静态文件拷贝到 Spring Boot 项目的 resources/static 目录下:

img

前端页面比较长,这里我把核心部分列出来(完整代码我会上传到 GitHub:https://github.com/lenve/spring-security-samples):

<form action="/login.html" method="post">
    <div class="input">
        <label for="name">用户名</label>
        <input type="text" name="username" id="name">
        <span class="spin"></span>
    </div>
    <div class="input">
        <label for="pass">密码</label>
        <input type="password" name="password" id="pass">
        <span class="spin"></span>
    </div>
    <div class="button login">
        <button type="submit">
            <span>登录</span>
            <i class="fa fa-check"></i>
        </button>
    </div>
</form>

form 表单中,注意 action 为 /login.html

实际上它还有一个隐藏的操作,就是登录接口地址也设置成 /login.html 了。换句话说,新的登录页面和登录接口地址都是 /login.html,现在存在如下两个请求:

前面的 GET 请求用来获取登录页面,后面的 POST 请求用来提交登录数据。

有的小伙伴会感到奇怪?为什么登录页面和登录接口不能分开配置呢?

其实是可以分开配置的!

在 SecurityConfig 中,我们可以通过 loginProcessingUrl 方法来指定登录接口地址,如下:

.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/doLogin")
.permitAll()
.and()

这样配置之后,登录页面地址和登录接口地址就分开了,各是各的。

此时我们还需要修改登录页面里边的 action 属性,改为 /doLogin,如下:

<form action="/doLogin" method="post">
<!--省略-->
</form>

此时,启动项目重新进行登录,我们发现依然可以登录成功。

那么为什么默认情况下两个配置地址是一样的呢?

我们知道,form 表单的相关配置在 FormLoginConfigurer 中,该类继承自 AbstractAuthenticationFilterConfigurer ,所以当 FormLoginConfigurer 初始化的时候,AbstractAuthenticationFilterConfigurer 也会初始化,在 AbstractAuthenticationFilterConfigurer 的构造方法中,我们可以看到:

protected AbstractAuthenticationFilterConfigurer() {
	setLoginPage("/login");
}

这就是配置默认的 loginPage 为 /login

另一方面,FormLoginConfigurer 的初始化方法 init 方法中也调用了父类的 init 方法:

public void init(H http) throws Exception {
	super.init(http);
	initDefaultLoginFilter(http);
}

而在父类的 init 方法中,又调用了 updateAuthenticationDefaults,我们来看下这个方法:

protected final void updateAuthenticationDefaults() {
	if (loginProcessingUrl == null) {
		loginProcessingUrl(loginPage);
	}
	//省略
}

从这个方法的逻辑中我们就可以看到,如果用户没有给 loginProcessingUrl 设置值的话,默认就使用 loginPage 作为 loginProcessingUrl。

而如果用户配置了 loginPage,在配置完 loginPage 之后,updateAuthenticationDefaults 方法还是会被调用,此时如果没有配置 loginProcessingUrl,则使用新配置的 loginPage 作为 loginProcessingUrl。

1.8 基于数据库的认证

创建库

/*!40101 SET NAMES utf8 */;

/*!40101 SET SQL_MODE=''*/;

/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE DATABASE /*!32312 IF NOT EXISTS*/`security` /*!40100 DEFAULT CHARACTER SET utf8 */;

USE `security`;

/*Table structure for table `role` */

DROP TABLE IF EXISTS `role`;

CREATE TABLE `role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(32) DEFAULT NULL,
  `nameZh` varchar(32) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;

/*Data for the table `role` */

insert  into `role`(`id`,`name`,`nameZh`) values 
(1,'ROLE_dba','数据库管理员'),
(2,'ROLE_admin','系统管理员'),
(3,'ROLE_user','用户');

/*Table structure for table `user` */

DROP TABLE IF EXISTS `user`;

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(32) DEFAULT NULL,
  `password` varchar(255) DEFAULT NULL,
  `enabled` tinyint(1) DEFAULT NULL,
  `locked` tinyint(1) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;

/*Data for the table `user` */

insert  into `user`(`id`,`username`,`password`,`enabled`,`locked`) values 
(1,'root','$2a$10$RMuFXGQ5AtH4wOvkUqyvuecpqUSeoxZYqilXzbz50dceRsga.WYiq',1,0),
(2,'admin','$2a$10$RMuFXGQ5AtH4wOvkUqyvuecpqUSeoxZYqilXzbz50dceRsga.WYiq',1,0),
(3,'sang','$2a$10$RMuFXGQ5AtH4wOvkUqyvuecpqUSeoxZYqilXzbz50dceRsga.WYiq',1,0);

/*Table structure for table `user_role` */

DROP TABLE IF EXISTS `user_role`;

CREATE TABLE `user_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `uid` int(11) DEFAULT NULL,
  `rid` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;

/*Data for the table `user_role` */

insert  into `user_role`(`id`,`uid`,`rid`) values 
(1,1,1),
(2,1,2),
(3,2,2),
(4,3,3);

/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

得到user表、user_role表、role表

image-20210525102214396

image-20210525102226106

image-20210525102243104

引入依赖

<properties>
    <java.version>1.8</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.1.0</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>1.1.10</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
        <version>5.1.27</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <resources>
        <resource>
            <directory>src/main/java</directory>
            <includes>
                <include>**/*.xml</include>
            </includes>
        </resource>
        <resource>
            <directory>src/main/resources</directory>
        </resource>
    </resources>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

配置文件

spring.datasource.url=jdbc:mysql://192.168.66.128:3306/security
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.username=root
spring.datasource.password=root

定义实体类

//实现认证的接口
public class User implements UserDetails {
    private Integer id;
    private String username;
    private String password;
    private Boolean enabled;
    private Boolean locked;
    private List<Role> roles;
  
 //重写接口中的方法
   @Override
    public String getUsername() {
        return username;
    }
		//账户是否未过期,对应user中的locked
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
		//是否没有被锁定
    @Override
    public boolean isAccountNonLocked() {
        return !locked;
    }

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

    @Override
    public boolean isEnabled() {
        return enabled;
    }
		//返回用户的所有角色
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (Role role : roles) {
          	//数据库的role表中用户必须以"ROLE_"开头
            authorities.add(new SimpleGrantedAuthority(role.getName()));
        }
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }
}

public class Role {
    private Integer id;
    private String name;
    private String nameZh;
  
  ...
}

创建Service

@Service
public class UserService implements UserDetailsService {
    @Autowired
    UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
      //方法一  
      User user = userMapper.loadUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在!");
        }
       //方法二
        user.setRoles(userMapper.getUserRolesById(user.getId()));
        return user;
    }
}

Mapper的接口以及实现类

@Mapper
public interface UserMapper {
    User loadUserByUsername(String username);

    List<Role> getUserRolesById(Integer id);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.javaboy.securitydb.mapper.UserMapper">
    <select id="loadUserByUsername" resultType="org.javaboy.securitydb.bean.User">
        select * from user where username=#{username}
    </select>
    
    <select id="getUserRolesById" resultType="org.javaboy.securitydb.bean.Role">
        select * from role where id in (select rid from user_role where uid=#{id})
    </select>
</mapper>

Security配置类

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    UserService userService;
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService);
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
  
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/dba/**").hasRole("dba")
                .antMatchers("/admin/**").hasRole("admin")
                .antMatchers("/user/**").hasRole("user")
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .permitAll()
                .and()
                .csrf().disable();
    }
}

测试接口Controller

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        return "hello security!";
    }

    @GetMapping("/dba/hello")
    public String dba() {
        return "hello dba!";
    }

    @GetMapping("/admin/hello")
    public String admin() {
        return "hello admin";
    }

    @GetMapping("/user/hello")
    public String user() {
        return "hello user";
    }
}

角色继承

角色继承实际上是一个很常见的需求,因为大部分公司治理可能都是金字塔形的,上司可能具备下属的部分甚至所有权限,这一现实场景,反映到我们的代码中,就是角色继承了。 Spring Security 中为开发者提供了相关的角色继承解决方案,但是这一解决方案在最近的 Spring Security 版本变迁中,使用方法有所变化

SpringSecurity 在角色继承上有两种不同的写法,在 Spring Boot2.0.8(对应 Spring Security 也是 5.0.11)上面是一种写法,从 Spring Boot2.1.0(对应 Spring Security5.1.1)又是另外一种写法

以前的写法

这里说的以前写法,就是指 SpringBoot2.0.8(含)之前的写法,在之前的写法中,角色继承只需要开发者提供一个 RoleHierarchy 接口的实例即可,例如下面这样:

@Bean
RoleHierarchy roleHierarchy() {
    RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
    String hierarchy = "ROLE_dba > ROLE_admin ROLE_admin > ROLE_user";
    roleHierarchy.setHierarchy(hierarchy);
    return roleHierarchy;
}

在这里我们提供了一个 RoleHierarchy 接口的实例,使用字符串来描述了角色之间的继承关系, ROLE_dba 具备 ROLE_admin 的所有权限,而 ROLE_admin 则具备 ROLE_user 的所有权限,继承与继承之间用一个空格隔开。提供了这个 Bean 之后,以后所有具备 ROLE_user 角色才能访问的资源, ROLE_dbaROLE_admin 也都能访问,具备 ROLE_amdin 角色才能访问的资源, ROLE_dba 也能访问。

现在的写法

但是上面这种写法仅限于 Spring Boot2.0.8(含)之前的版本,在之后的版本中,这种写法则不被支持,新版的写法是下面这样:

@Bean
RoleHierarchy roleHierarchy() {
    RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
    String hierarchy = "ROLE_dba > ROLE_admin \n ROLE_admin > ROLE_user";
    roleHierarchy.setHierarchy(hierarchy);
    return roleHierarchy;
}

变化主要就是分隔符,将原来用空格隔开的地方,现在用换行符了。这里表达式的含义依然和上面一样,不再赘述。

上面两种不同写法都是配置角色的继承关系,配置完成后,接下来指定角色和资源的对应关系即可,如下:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests().antMatchers("/admin/**")
            .hasRole("admin")
            .antMatchers("/db/**")
            .hasRole("dba")
            .antMatchers("/user/**")
            .hasRole("user")
            .and()
            .formLogin()
            .loginProcessingUrl("/doLogin")
            .permitAll()
            .and()
            .csrf().disable();
}

这个表示 /db/** 格式的路径需要具备 dba 角色才能访问, /admin/** 格式的路径则需要具备 admin 角色才能访问, /user/** 格式的路径,则需要具备 user 角色才能访问,此时提供相关接口,会发现,dba 除了访问 /db/**,也能访问 /admin/**/user/** ,admin 角色除了访问 /admin/** ,也能访问 /user/** ,user 角色则只能访问 /user/**

动态权限配置

继续创建表

/*Table structure for table `menu` */

DROP TABLE IF EXISTS `menu`;

CREATE TABLE `menu` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `pattern` varchar(128) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;

/*Data for the table `menu` */

insert  into `menu`(`id`,`pattern`) values 
(1,'/db/**'),
(2,'/admin/**'),
(3,'/user/**');

/*Table structure for table `menu_role` */

DROP TABLE IF EXISTS `menu_role`;

CREATE TABLE `menu_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `mid` int(11) DEFAULT NULL,
  `rid` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;

/*Data for the table `menu_role` */

insert  into `menu_role`(`id`,`mid`,`rid`) values 
(1,1,1),
(2,2,2),
(3,3,3);

menu表、menu_role表

image-20210525105223533

image-20210525105242464

最终的五张表

image-20210525105211929

上面的角色之间的权限是在Config类中写死的

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers("/dba/**").hasRole("dba")
            .antMatchers("/admin/**").hasRole("admin")
            .antMatchers("/user/**").hasRole("user")
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .permitAll()
            .and()
            .csrf().disable();
}

如今转换到数据库中动态加载,新增实体类

public class Menu {
    private Integer id;
    private String pattern;
    private List<Role> roles;

    @Override
    public String toString() {
        return "Menu{" +
                "id=" + id +
                ", pattern='" + pattern + '\'' +
                ", roles=" + roles +
                '}';
    }

    public List<Role> getRoles() {
        return roles;
    }

    public void setRoles(List<Role> roles) {
        this.roles = roles;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getPattern() {
        return pattern;
    }

    public void setPattern(String pattern) {
        this.pattern = pattern;
    }
}

Service及Mapper

@Service
public class MenuService {
    @Autowired
    MenuMapper menuMapper;
    public List<Menu> getAllMenus() {
        return menuMapper.getAllMenus();
    }
}

public interface MenuMapper {
    List<Menu> getAllMenus();
}

查询一对多的关系

select m.*,r.`id` as rid,r.`name` as rname,r.`nameZh` as rnameZh from menu m left join menu_role mr on m.`id`=mr.`mid` left join role r on mr.`rid`=r.`id`

image-20210525111027555

对应的Menumapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.javaboy.securitydy.mapper.MenuMapper">
    
    <resultMap id="BaseResultMap" type="org.javaboy.securitydy.bean.Menu">
        <id property="id" column="id"/>
        <result property="pattern" column="pattern"/>
        <collection property="roles" ofType="org.javaboy.securitydy.bean.Role">
            <id column="rid" property="id"/>
            <result column="rname" property="name"/>
            <result column="rnameZh" property="nameZh"/>
        </collection>
    </resultMap>
            <!-- 一对多,需要resultMap-->
    <select id="getAllMenus" resultMap="BaseResultMap">
        select m.*,r.`id` as rid,r.`name` as rname,r.`nameZh` as rnameZh from menu m left join menu_role mr on m.`id`=mr.`mid` left join role r on mr.`rid`=r.`id`
    </select>
</mapper>

在更新配置类之前,先创建过滤器根据url匹配用户角色

@Component
public class MyFilter implements FilterInvocationSecurityMetadataSource {
    //路径匹配
    AntPathMatcher pathMatcher = new AntPathMatcher();
    @Autowired
    MenuService menuService;

    //根据请求的地址分析需要那个角色
    @Override
    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
        String requestUrl = ((FilterInvocation) o).getRequestUrl();
        List<Menu> allMenus = menuService.getAllMenus();
        //遍历从数据库取出的结果,开始匹配
        for (Menu menu : allMenus) {
            if (pathMatcher.match(menu.getPattern(), requestUrl)) {
                //url对应得上库中的结果
                List<Role> roles = menu.getRoles();
                String[] rolesStr = new String[roles.size()];
                for (int i = 0; i < roles.size(); i++) {
                    rolesStr[i] = roles.get(i).getName();
                }
                return SecurityConfig.createList(rolesStr);
            }
        }
        //url无法匹配,特殊标记
        return SecurityConfig.createList("ROLE_login");
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

根据过滤器Collection得到的返回值,再进行处理Collection

@Component
public class MyAccessDecisionManager implements AccessDecisionManager {
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
        for (ConfigAttribute attribute : collection) {
            //说明url没匹配上
            if ("ROLE_login".equals(attribute.getAttribute())) {
                //如果是匿名用户,抛异常
                if (authentication instanceof AnonymousAuthenticationToken) {
                    throw new AccessDeniedException("非法请求!");
                } else {
                    return;
                }
            }
            //匹配得上
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equals(attribute.getAttribute())) {
                    return;
                }
            }
        }
        throw new AccessDeniedException("非法请求!");
    }

    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

更新配置类

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    UserService userService;
    //注入过滤器
    @Autowired
    MyFilter myFilter;
    //注入处理
    @Autowired
    MyAccessDecisionManager myAccessDecisionManager;

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                        //使用过滤器和处理
                        o.setAccessDecisionManager(myAccessDecisionManager);
                        o.setSecurityMetadataSource(myFilter);
                        return o;
                    }
                })
                .and()
                .formLogin()
                .permitAll()
                .and()
                .csrf().disable();
    }
}

1.9 使用JSON登录

使用Key/Value登录

  • 创建 Spring Boot 工程

首先创建 SpringBoot 工程,添加 SpringSecurity 依赖,如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
  • 添加 Security 配置

创建 SecurityConfig,完成 SpringSecurity 的配置,如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication().withUser("zhangsan").password("$2a$10$2O4EwLrrFPEboTfDOtC0F.RpUMk.3q3KvBHRx7XXKUMLBGjOOBs8q").roles("user");
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginProcessingUrl("/doLogin")
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
                        RespBean ok = RespBean.ok("登录成功!",authentication.getPrincipal());
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        out.write(new ObjectMapper().writeValueAsString(ok));
                        out.flush();
                        out.close();
                    }
                })
                .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp, AuthenticationException e) throws IOException, ServletException {
                        RespBean error = RespBean.error("登录失败");
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        out.write(new ObjectMapper().writeValueAsString(error));
                        out.flush();
                        out.close();
                    }
                })
                .loginPage("/login")
                .permitAll()
                .and()
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override
                    public void onLogoutSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
                        RespBean ok = RespBean.ok("注销成功!");
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        out.write(new ObjectMapper().writeValueAsString(ok));
                        out.flush();
                        out.close();
                    }
                })
                .permitAll()
                .and()
                .csrf()
                .disable()
                .exceptionHandling()
                .accessDeniedHandler(new AccessDeniedHandler() {
                    @Override
                    public void handle(HttpServletRequest req, HttpServletResponse resp, AccessDeniedException e) throws IOException, ServletException {
                        RespBean error = RespBean.error("权限不足,访问失败");
                        resp.setStatus(403);
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        out.write(new ObjectMapper().writeValueAsString(error));
                        out.flush();
                        out.close();
                    }
                });

    }
}

这里的配置虽然有点长,但是很基础,配置含义也比较清晰,首先提供 BCryptPasswordEncoder 作为 PasswordEncoder ,可以实现对密码的自动加密加盐,非常方便,然后提供了一个名为 zhangsan 的用户,密码是 123 ,角色是 user ,最后配置登录逻辑,所有的请求都需要登录后才能访问,登录接口是 /doLogin ,用户名的 key 是 username ,密码的 key 是 password ,同时配置登录成功、登录失败以及注销成功、权限不足时都给用户返回JSON提示,另外,这里虽然配置了登录页面为 /login ,实际上这不是一个页面,而是一段 JSON ,在 LoginController 中提供该接口,如下:

@RestController
@ResponseBody
public class LoginController {
    @GetMapping("/login")
    public RespBean login() {
        return RespBean.error("尚未登录,请登录");
    }
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}

这里 /login 只是一个 JSON 提示,而不是页面, /hello 则是一个测试接口。

OK,做完上述步骤就可以开始测试了,运行SpringBoot项目,访问 /hello 接口,结果如下:

img

此时先调用登录接口进行登录,如下:

img

登录成功后,再去访问 /hello 接口就可以成功访问了。

使用JSON登录

上面演示的是一种原始的登录方案,如果想将用户名密码通过 JSON 的方式进行传递,则需要自定义相关过滤器,通过分析源码我们发现,默认的用户名密码提取在 UsernamePasswordAuthenticationFilter 过滤器中,部分源码如下:

public class UsernamePasswordAuthenticationFilter extends
		AbstractAuthenticationProcessingFilter {
	public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
	public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

	private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
	private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
	private boolean postOnly = true;
	public UsernamePasswordAuthenticationFilter() {
		super(new AntPathRequestMatcher("/login", "POST"));
	}

	public Authentication attemptAuthentication(HttpServletRequest request,
			HttpServletResponse response) throws AuthenticationException {
    
		if (postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException(
					"Authentication method not supported: " + request.getMethod());
		}

		String username = obtainUsername(request);
		String password = obtainPassword(request);

		if (username == null) {
			username = "";
		}

		if (password == null) {
			password = "";
		}

		username = username.trim();

		UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
				username, password);

		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);

		return this.getAuthenticationManager().authenticate(authRequest);
	}

	protected String obtainPassword(HttpServletRequest request) {
		return request.getParameter(passwordParameter);
	}

	protected String obtainUsername(HttpServletRequest request) {
		return request.getParameter(usernameParameter);
	}
    //...
    //...
}

从这里可以看到,默认的用户名/密码提取就是通过 request 中的 getParameter 来提取的,如果想使用 JSON 传递用户名密码,只需要将这个过滤器替换掉即可,自定义过滤器如下:

public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
      
        if (request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)
                || request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)) {
            ObjectMapper mapper = new ObjectMapper();
            UsernamePasswordAuthenticationToken authRequest = null;
            try (InputStream is = request.getInputStream()) {
                Map<String,String> authenticationBean = mapper.readValue(is, Map.class);
                authRequest = new UsernamePasswordAuthenticationToken(
                        authenticationBean.get("username"), authenticationBean.get("password"));
            } catch (IOException e) {
                e.printStackTrace();
                authRequest = new UsernamePasswordAuthenticationToken(
                        "", "");
            } finally {
                setDetails(request, authRequest);
                return this.getAuthenticationManager().authenticate(authRequest);
            }
        }
        else {
            return super.attemptAuthentication(request, response);
        }
    }
}

这里只是将用户名/密码的获取方案重新修正下,改为了从 JSON 中获取用户名密码,然后在 SecurityConfig 中作出如下修改:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests().anyRequest().authenticated()
            .and()
            .formLogin()
            .and().csrf().disable();
    http.addFilterAt(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
@Bean
CustomAuthenticationFilter customAuthenticationFilter() throws Exception {
    CustomAuthenticationFilter filter = new CustomAuthenticationFilter();
    filter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() {
        @Override
        public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
            resp.setContentType("application/json;charset=utf-8");
            PrintWriter out = resp.getWriter();
            RespBean respBean = RespBean.ok("登录成功!");
            out.write(new ObjectMapper().writeValueAsString(respBean));
            out.flush();
            out.close();
        }
    });
    filter.setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
        @Override
        public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp, AuthenticationException e) throws IOException, ServletException {
            resp.setContentType("application/json;charset=utf-8");
            PrintWriter out = resp.getWriter();
            RespBean respBean = RespBean.error("登录失败!");
            out.write(new ObjectMapper().writeValueAsString(respBean));
            out.flush();
            out.close();
        }
    });
    filter.setAuthenticationManager(authenticationManagerBean());
    return filter;
}

将自定义的 CustomAuthenticationFilter 类加入进来即可,接下来就可以使用 JSON 进行登录了,如下:

img

2. Shiro

2.1 简介

Apache Shiro是一个开源安全框架,提供身份验证、授权、密码学和会话管理。Shiro框架具有直观、易用等特性,同时也能提供健壮的安全性,虽然它的功能不如SpringSecurity那么强大,但是在普通的项目中也够用了。

2.1.1 由来

Shiro的前身是JSecurity,2004年,Les Hazlewood和Jeremy Haile创办了Jsecurity。当时他们找不到适用于应用程序级别的合适Java安全框架,同时又对JAAS非常失望。2004年到2008年期间,JSecurity托管在SourceForge上,贡献者包括Peter Ledbrook、Alan Ditzel和Tim Veil。2008年,JSecurity项目贡献给了Apache软件基金会(ASF),并被接纳成为Apache Incubator项目,由导师管理,目标是成为一个顶级Apache项目。期间,Jsecurity曾短暂更名为Ki,随后因商标问题被社区更名为“Shiro”。随后项目持续在Apache Incubator中孵化,并增加了贡献者Kalle Korhonen。2010年7月,Shiro社区发布了1.0版,随后社区创建了其项目管理委员会,并选举Les Hazlewood为主席。2010年9月22日,Shrio成为Apache软件基金会的顶级项目(TLP)。

3.1.2 有哪些功能

Apache Shiro是一个强大而灵活的开源安全框架,它干净利落地处理身份认证,授权,企业会话管理和加密。Apache Shiro的首要目标是易于使用和理解。安全有时候是很复杂的,甚至是痛苦的,但它没有必要这样。框架应该尽可能掩盖复杂的地方,露出一个干净而直观的API,来简化开发人员在应用程序安全上所花费的时间。

以下是你可以用Apache Shiro 所做的事情:

  • 验证用户来核实他们的身份
  • 对用户执行访问控制,如:判断用户是否被分配了一个确定的安全角色;判断用户是否被允许做某事
  • 在任何环境下使用Session API,即使没有Web容器
  • 在身份验证,访问控制期间或在会话的生命周期,对事件作出反应
  • 聚集一个或多个用户安全数据的数据源,并作为一个单一的复合用户“视图”
  • 单点登录(SSO)功能
  • 为没有关联到登录的用户启用”Remember Me”服务

Apache Shiro是一个拥有许多功能的综合性的程序安全框架。下面的图表展示了Shiro的重点:

p306

Shiro中有四大基石——身份验证,授权,会话管理和加密

  1. Authentication:有时也简称为“登录”,这是一个证明用户是谁的行为。
  2. Authorization:访问控制的过程,也就是决定“谁”去访问“什么”。
  3. Session Management:管理用户特定的会话,即使在非Web 或EJB 应用程序。
  4. Cryptography:通过使用加密算法保持数据安全同时易于使用。

除此之外,Shiro也提供了额外的功能来解决在不同环境下所面临的安全问题,尤其是以下这些:

  1. Web Support:Shiro的web支持的API能够轻松地帮助保护Web应用程序。
  2. Caching:缓存是Apache Shiro中的第一层公民,来确保安全操作快速而又高效。
  3. Concurrency:Apache Shiro利用它的并发特性来支持多线程应用程序。
  4. Testing:测试支持的存在来帮助你编写单元测试和集成测试。
  5. “Run As”:一个允许用户假设为另一个用户身份(如果允许)的功能,有时候在管理脚本很有用。
  6. “Remember Me”:在会话中记住用户的身份,这样用户只需要在强制登录时候登录。

2.2 Realm

2.2.1 登录流程是什么样的

首先我们来看shiro官方文档中这样一张登录流程图:

p308

参照此图,我们的登录一共要经过如下几个步骤:

  1. 应用程序代码调用Subject.login方法,传递创建好的包含终端用户的Principals(身份)和Credentials(凭证)的AuthenticationToken实例(即上文例子中的UsernamePasswordToken)。
  2. Subject实例,通常是DelegatingSubject(或子类)委托应用程序的SecurityManager通过调用securityManager.login(token)开始真正的验证工作(在DelegatingSubject类的login方法中打断点即可看到)。
  3. SubjectManager作为一个基本的“保护伞”的组成部分,接收token以及简单地委托给内部的Authenticator实例通过调用authenticator.authenticate(token)。这通常是一个ModularRealmAuthenticator实例,支持在身份验证中协调一个或多个Realm实例。ModularRealmAuthenticator本质上为Apache Shiro 提供了PAM-style 范式(其中在PAM 术语中每个Realm 都是一个’module’)。
  4. 如果应用程序中配置了一个以上的Realm,ModularRealmAuthenticator实例将利用配置好的AuthenticationStrategy来启动Multi-Realm认证尝试。在Realms 被身份验证调用之前,期间和以后,AuthenticationStrategy被调用使其能够对每个Realm的结果作出反应。如果只有一个单一的Realm 被配置,它将被直接调用,因为没有必要为一个单一Realm的应用使用AuthenticationStrategy。
  5. 每个配置的Realm用来帮助看它是否支持提交的AuthenticationToken。如果支持,那么支持Realm的getAuthenticationInfo方法将会伴随着提交的token被调用。

2.2.2 什么是Realm

根据Realm文档上的解释,Realms担当Shiro和你的应用程序的安全数据之间的“桥梁”或“连接器”。当它实际上与安全相关的数据如用来执行身份验证(登录)及授权(访问控制)的用户帐户交互时,Shiro从一个或多个为应用程序配置的Realm 中寻找许多这样的东西。在这个意义上说,Realm 本质上是一个特定安全的DAO:它封装了数据源的连接详细信息,使Shiro 所需的相关的数据可用。当配置Shiro 时,你必须指定至少一个Realm 用来进行身份验证和/或授权。SecurityManager可能配置多个Realms,但至少有一个是必须的。Shiro 提供了立即可用的Realms 来连接一些安全数据源(即目录),如LDAP,关系数据库(JDBC),文本配置源,像INI 及属性文件,以及更多。

2.2 整合SpringBoot

方式一

引入依赖

<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-web</artifactId>
  <version>1.4.0</version>
</dependency>
<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-spring</artifactId>
  <version>1.4.0</version>
</dependency>

自定义Realm

public class MyRealm extends AuthorizingRealm {
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String username = (String) token.getPrincipal();
        if ("javaboy".equals(username)) {
            return new SimpleAuthenticationInfo(username, "123", getName());
        }
        return null;
    }
}

在 Realm 中实现简单的认证操作即可,不做授权,授权的具体写法和 SSM 中的 Shiro 一样,不赘述。这里的认证表示用户名必须是 javaboy ,用户密码必须是 123 ,满足这样的条件,就能登录成功!

配置类

@Configuration
public class ShiroConfig {
    @Bean
    MyRealm myRealm() {
        return new MyRealm();
    }

    @Bean
    SecurityManager securityManager() {
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        manager.setRealm(myRealm());
        return manager;
    }

    @Bean
    ShiroFilterFactoryBean shiroFilterFactoryBean() {
        ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
        bean.setSecurityManager(securityManager());
        bean.setLoginUrl("/login");
        bean.setSuccessUrl("/index");
        Map<String, String> map = new LinkedHashMap<>();
        map.put("/doLogin", "anon");
        map.put("/**", "authc");
        bean.setFilterChainDefinitionMap(map);
        return bean;
    }
}

在这里进行 Shiro 的配置主要配置 3 个 Bean :

  • 首先需要提供一个 Realm 的实例
  • 需要配置一个 SecurityManager,在 SecurityManager 中配置 Realm
  • 配置一个 ShiroFilterFactoryBean ,在 ShiroFilterFactoryBean 中指定路径拦截规则等
  • 配置登录和测试接口
  • 其中,ShiroFilterFactoryBean 的配置稍微多一些,配置含义如下:
    • setSecurityManager 表示指定 SecurityManager
    • setLoginUrl 表示指定登录页面
    • setSuccessUrl 表示指定登录成功页面
    • 接下来的 Map 中配置了路径拦截规则,注意,要有序

测试类

@RestController
public class HelloController {
    @GetMapping("/login")
    public String loging() {
        return "please login";
    }

    @PostMapping("/doLogin")
    public void doLogin(String username, String password) {
        Subject subject = SecurityUtils.getSubject();
        try {
            subject.login(new UsernamePasswordToken(username, password));
            System.out.println("success");
        } catch (AuthenticationException e) {
            e.printStackTrace();
            System.out.println("fail>>" + e.getMessage());
        }
    }

    @GetMapping("/hello")
    public String hello() {
        return "hello shiro!";
    }
}

方式二

上面这种配置方式实际上相当于把 SSM 中的 XML 配置拿到 Spring Boot 中用 Java 代码重新写了一遍,除了这种方式之外,我们也可以直接使用 Shiro 官方提供的 Starter

依赖

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
    <version>1.4.0</version>
</dependency>

配置类

shiro.sessionManager.sessionIdCookieEnabled=true
shiro.sessionManager.sessionIdUrlRewritingEnabled=true
shiro.unauthorizedUrl=/unauthorizedurl
shiro.web.enabled=true
shiro.successUrl=/success
shiro.loginUrl=/login
  • 第一行表示是否允许将sessionId 放到 cookie 中
  • 第二行表示是否允许将 sessionId 放到 Url 地址拦中
  • 第三行表示访问未获授权的页面时,默认的跳转路径
  • 第四行表示开启 shiro
  • 第五行表示登录成功的跳转页面
  • 第六行表示登录页面

配置类

@Configuration
public class ShiroConfig {
    @Bean
    Realm realm() {
        TextConfigurationRealm realm = new TextConfigurationRealm();
        realm.setUserDefinitions("javaboy=123,user \n admin=123,admin");
        realm.setRoleDefinitions("admin=read,write \n user=read");
        return realm;
    }
  	//配置拦截规则
    @Bean
    ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition definition = new DefaultShiroFilterChainDefinition();
        definition.addPathDefinition("/doLogin", "anon");
        definition.addPathDefinition("/**", "authc");
        return definition;
    }
}

测试类

@RestController
public class LoginController {
    @GetMapping("/hello")
    public String hello() {
        return "hello shiro!";
    }

    @GetMapping("/login")
    public String login() {
        return "please login";
    }

    @PostMapping("/doLogin")
    public void doLogin(String username, String password) {
        Subject subject = SecurityUtils.getSubject();
        try {
            subject.login(new UsernamePasswordToken(username, password));
            System.out.println("success");
        } catch (AuthenticationException e) {
            e.printStackTrace();
            System.out.println("fail>" + e.getMessage());
        }
    }
}

3. OAuth2

3.1 引入

住在一个大型的居民小区,小区有门禁系统,进入的时候需要输入密码,我经常网购和外卖,每天都有快递员来送货。我必须找到一个办法,让快递员通过门禁系统,进入小区,如果我把自己的密码,告诉快递员,他就拥有了与我同样的权限,这样好像不太合适。万一我想取消他进入小区的权力,也很麻烦,我自己的密码也得跟着改了,还得通知其他的快递员。有没有一种办法,让快递员能够自由进入小区,又不必知道小区居民的密码,而且他的唯一权限就是送货,其他需要密码的场合,他都没有权限。

授权机制的设计

于是,我设计了一套授权机制。

第一步,门禁系统的密码输入器下面,增加一个按钮,叫做”获取授权”。快递员需要首先按这个按钮,去申请授权。

第二步,他按下按钮以后,屋主(也就是我)的手机就会跳出对话框:有人正在要求授权。系统还会显示该快递员的姓名、工号和所属的快递公司。

我确认请求属实,就点击按钮,告诉门禁系统,我同意给予他进入小区的授权。

第三步,门禁系统得到我的确认以后,向快递员显示一个进入小区的令牌(access token)。令牌就是类似密码的一串数字,只在短期内(比如七天)有效。

第四步,快递员向门禁系统输入令牌,进入小区。

有人可能会问,为什么不是远程为快递员开门,而要为他单独生成一个令牌?这是因为快递员可能每天都会来送货,第二天他还可以复用这个令牌。另外,有的小区有多重门禁,快递员可以使用同一个令牌通过它们。

互联网场景

我们把上面的例子搬到互联网,就是 OAuth 的设计了。

首先,居民小区就是储存用户数据的网络服务。比如,微信储存了我的好友信息,获取这些信息,就必须经过微信的”门禁系统”。

其次,快递员(或者说快递公司)就是第三方应用,想要穿过门禁系统,进入小区。

最后,我就是用户本人,同意授权第三方应用进入小区,获取我的数据。

简单说,OAuth 就是一种授权机制。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。

令牌与密码

令牌(token)与密码(password)的作用是一样的,都可以进入系统,但是有三点差异。

(1)令牌是短期的,到期会自动失效,用户自己无法修改。密码一般长期有效,用户不修改,就不会发生变化。

(2)令牌可以被数据所有者撤销,会立即失效。以上例而言,屋主可以随时取消快递员的令牌。密码一般不允许被他人撤销。

(3)令牌有权限范围(scope),比如只能进小区的二号门。对于网络服务来说,只读令牌就比读写令牌更安全。密码一般是完整权限。

上面这些设计,保证了令牌既可以让第三方应用获得权限,同时又随时可控,不会危及系统安全。这就是 OAuth2.0 的优点。

注意,只要知道了令牌,就能进入系统。系统一般不会再次确认身份,所以令牌必须保密,泄漏令牌与泄漏密码的后果是一样的。 这也是为什么令牌的有效期,一般都设置得很短的原因。

OAuth2.0 对于如何颁发令牌的细节,规定得非常详细。具体来说,一共分成四种授权类型(authorization grant),即四种颁发令牌的方式,适用于不同的互联网场景。

3.2 简介

OAuth 是一个开放标准,该标准允许用户让第三方应用访问该用户在某一网站上存储的私密资源(如头像、照片、视频等),而在这个过程中无需将用户名和密码提供给第三方应用。实现这一功能是通过提供一个令牌(token),而不是用户名和密码来访问他们存放在特定服务提供者的数据。采用令牌(token)的方式可以让用户灵活的对第三方应用授权或者收回权限。

OAuth2 是 OAuth 协议的下一版本,但不向下兼容 OAuth 1.0。传统的 Web 开发登录认证一般都是基于 session 的,但是在前后端分离的架构中继续使用 session 就会有许多不便,因为移动端(Android、iOS、微信小程序等)要么不支持 cookie(微信小程序),要么使用非常不便,对于这些问题,使用 OAuth2 认证都能解决。

对于大家而言,我们在互联网应用中最常见的 OAuth2 应该就是各种第三方登录了,例如 QQ 授权登录、微信授权登录、微博授权登录、GitHub 授权登录等等。

3.3 四种模式

OAuth2 协议一共支持 4 种不同的授权模式:

  1. 授权码模式:常见的第三方平台登录功能基本都是使用这种模式。
  2. 简化模式:简化模式是不需要客户端服务器参与,直接在浏览器中向授权服务器申请令牌(token),一般如果网站是纯静态页面则可以采用这种方式。
  3. 密码模式:密码模式是用户把用户名密码直接告诉客户端,客户端使用这些信息向授权服务器申请令牌(token)。这需要用户对客户端高度信任,例如客户端应用和服务提供商就是同一家公司,我们自己做前后端分离登录就可以采用这种模式。
  4. 客户端模式:客户端模式是指客户端使用自己的名义而不是用户的名义向服务提供者申请授权,严格来说,客户端模式并不能算作 OAuth 协议要解决的问题的一种解决方案,但是,对于开发者而言,在一些前后端分离应用或者为移动端提供的认证授权服务器上使用这种模式还是非常方便的。

3.2.1 授权码模式

授权码模式是最安全并且使用最广泛的一种模式。假如我要引入微信登录功能,那么我的流程可能是这样:

图片源自网络

在授权码模式中,我们分授权服务器和资源服务器,授权服务器用来派发 Token,拿着 Token 则可以去资源服务器获取资源,这两个服务器可以分开,也可以合并。

上面这张流程图的含义,具体是这样:

  • 首先,在网页上放一个超链接(我的网站相当于是第三方应用),用户 A (服务方的用户,例如微信用户)点击这个超链接就会去请求授权服务器(微信的授权服务器),用户点击的过程其实也就是我跟用户要授权的过程,这就是上图中的 1、2 步。
  • 接下来的第三步,就是用户点击了超链接之后,像授权服务器发送请求,一般来说,我放在 www.javaboy.org 网页上的超链接可能有如下参数:
https://wx.qq.com/oauth/authorize?response_type=code&client_id=javaboy&redirect_uri=www.javaboy.org&scope=all
  1. response_type 表示授权类型,使用授权码模式的时候这里固定为 code,表示要求返回授权码(将来拿着这个授权码去获取 access_token)
  2. client_id 表示客户端 id,也就是我应用的 id。有的小伙伴对这个不好理解,我说一下,如果我想让我的 www.javaboy.org 接入微信登录功能,我肯定得去微信开放平台注册,去填入我自己应用的基本信息等等,弄完之后,微信会给我一个 APPID,也就是我这里的 client_id,所以,从这里可以看出,授权服务器在校验的时候,会做两件事:1.校验客户端的身份;2.校验用户身份。
  3. redirect_uri 表示用户登录在成功/失败后,跳转的地址(成功登录微信后,跳转到 www.javaboy.org 中的哪个页面),跳转的时候,还会携带上一个授权码参数。
  4. scope 表示授权范围,即 www.javaboy.org 这个网站拿着用户的 token 都能干啥(一般来说就是获取用户非敏感的基本信息)
  • 接下来第四步,www.javaboy.org 这个网站,拿着第三步获取到的 code 以及自己的 client_id 和 client_secret 以及其他一些信息去授权服务器请求令牌,微信的授权服务器在校验过这些数据之后,就会发送一个令牌回来。这个过程一般是在后端完成的,而不是利用 js 去完成。

  • 接下来拿着这个 token,我们就可以去请求用户信息了。

一般情况下我们认为授权码模式是四种模式中最安全的一种模式,因为这种模式我们的 access_token 不用经过浏览器或者移动端 App,是直接从我们的后台发送到授权服务器上,这样就很大程度减少了 access_token 泄漏的风险。

3.3.2 简化模式

技术栈搭建的博客/电子书都是典型的纯前端应用,就是只有页面,没有后端,对于这种情况,如果我想接入微信登录该怎么办呢?这就用到了我们说的简化模式。

我们来看下简化模式的流程图:

图片源自网络

这个流程是这样:

  1. 在我 www.javaboy.org 网站上有一个微信登录的超链接,这个超链接类似下面这样:
https://wx.qq.com/oauth/authorize?response_type=token&client_id=javaboy&redirect_uri=www.javaboy.org&scope=all

这里的参数和前面授权码模式的基本相同,只有 response_type 的值不一样,这里是 token,表示要求授权服务器直接返回 access_token。

  1. 用户点击我这个超链接之后,就会跳转到微信登录页面,然后用户进行登录。
  2. 用户登录成功后,微信会自动重定向到 redirect_uri 参数指定的跳转网址,同时携带上 access_token,这样用户在前端就获取到 access_token 了。

简化模式的弊端很明显,因为没有后端,所以非常不安全,除非你对安全性要求不高,否则不建议使用。

3.3.3 密码模式

密码模式在 Spring Cloud 项目中有着非常广泛的应用

密码模式有一个前提就是你高度信任第三方应用,举个不恰当的例子:如果我要在 www.javaboy.org 这个网站上接入微信登录,我使用了密码模式,那你就要在 www.javaboy.org 这个网站去输入微信的用户名密码,这肯定是不靠谱的,所以密码模式需要你非常信任第三方应用。

微服务中有一个特殊的场景,就是服务之间的调用,用密码模式做鉴权是非常恰当不过的了。这个以后再细说。

我们来看下密码模式的流程:

img

密码式的流程比较简单:

  • 首先 www.javaboy.org 会发送一个 post 请求,类似下面这样的:
https://wx.qq.com/oauth/authorize?response_type=password&client_id=javaboy&username=江南一点雨&password=123

这里的参数和前面授权码模式的略有差异,response_type 的值不一样,这里是 password,表示密码式,另外多了用户名/密码参数,没有重定向的 redirect_uri ,因为这里不需要重定向。

  • 微信校验过用户名/密码之后,直接在 HTTP 响应中把 access_token 返回给客户端。

3.3.4 客户端模式

有的应用可能没有前端页面,就是一个后台,比如

这种应用开发好了就没有后台。

我们来看一个客户端模式的流程图:

img

这个步骤也很简单,就两步:

  • 客户端发送一个请求到授权服务器,请求格式如下:
GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&client_id=APPID&client_secret=APPSECRET

这里有三个参数,含义如下:

grant_type,获取access_token填写client_credential

client_id 和 client_secret 用来确认客户端的身份

  • 授权服务器通过验证后,会直接返回 access_token 给客户端。

大家发现,在这个过程中好像没有用户什么事了!是的,客户端模式给出的令牌,就是针对第三方应用的,而不是针对用户的。

在接入微信公众号后台的时候,有一个获取 Access_token 的步骤,其实就是这种模式,我截了一张微信开发平台文档的图,大家看下:

img

3.4 整合到SpringBoot

引入依赖

<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
    <version>2.3.6.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

配置文件

spring.redis.host=192.168.66.128
spring.redis.port=6379
spring.redis.password=123
spring.redis.database=0

授权服务器配置(密码模式)

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    AuthenticationManager authenticationManager;
    @Autowired
    RedisConnectionFactory redisConnectionFactory;
    @Autowired
    UserDetailsService userDetailsService;
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    //token的配置
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("password")
                .authorizedGrantTypes("password", "refresh_token")
                .accessTokenValiditySeconds(1800)
                .resourceIds("rid")
                .scopes("all")
                .secret("$2a$10$kwLIAqAupvY87OM.O25.Yu1QKEXV1imAv7jWbDaQRFUFWSnSiDEwG");
    }
    //token的存储
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(new RedisTokenStore(redisConnectionFactory))
                .authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService);
    }
    //登录认证
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients();
    }
}

配置资源服务器

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("rid").stateless(true);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/admin/**").hasRole("admin")
                .antMatchers("/user/**").hasRole("user")
                .anyRequest().authenticated();
    }
}

Security配置类

@Configuration
public class SecurityConfig  extends WebSecurityConfigurerAdapter {
    @Override
    @Bean
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Bean
    @Override
    protected UserDetailsService userDetailsService() {
        return super.userDetailsService();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("javaboy").password("$2a$10$kwLIAqAupvY87OM.O25.Yu1QKEXV1imAv7jWbDaQRFUFWSnSiDEwG").roles("admin")
                .and()
                .withUser("江南一点雨")
                .password("$2a$10$kwLIAqAupvY87OM.O25.Yu1QKEXV1imAv7jWbDaQRFUFWSnSiDEwG")
                .roles("user");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.antMatcher("/oauth/**")
                .authorizeRequests()
                .antMatchers("/oauth/**").permitAll()
                .and().csrf().disable();
    }
}

4. JWT

在前后端分离的项目中,上面介绍了Oauth2的方式,不过 JWT 算是目前比较流行的一种解决方案了,本文就和大家来分享一下如何将 Spring Security 和 JWT 结合在一起使用,进而实现前后端分离时的登录解决方案。

1 无状态登录

1.1 什么是有状态?

有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如Tomcat中的Session。例如登录:用户登录后,我们把用户的信息保存在服务端session中,并且给用户一个cookie值,记录对应的session,然后下次请求,用户携带cookie值来(这一步有浏览器自动完成),我们就能识别到对应session,从而找到用户的信息。这种方式目前来看最方便,但是也有一些缺陷,如下:

  • 服务端保存大量数据,增加服务端压力
  • 服务端保存用户状态,不支持集群化部署

1.2 什么是无状态

微服务集群中的每个服务,对外提供的都使用RESTful风格的接口。而RESTful风格的一个最重要的规范就是:服务的无状态性,即:

  • 服务端不保存任何客户端请求者信息
  • 客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份

那么这种无状态性有哪些好处呢?

  • 客户端请求不依赖服务端的信息,多次请求不需要必须访问到同一台服务器
  • 服务端的集群和状态对客户端透明
  • 服务端可以任意的迁移和伸缩(可以方便的进行集群化部署)
  • 减小服务端存储压力

1.3.如何实现无状态

无状态登录的流程:

  • 首先客户端发送账户名/密码到服务端进行认证
  • 认证通过后,服务端将用户信息加密并且编码成一个token,返回给客户端
  • 以后客户端每次发送请求,都需要携带认证的token
  • 服务端对客户端发送来的token进行解密,判断是否有效,并且获取用户登录信息

1.4 JWT

1.4.1 简介

JWT,全称是Json Web Token, 是一种JSON风格的轻量级的授权和身份认证规范,可实现无状态、分布式的Web应用授权,JWT 作为一种规范,并没有和某一种语言绑定在一起,常用的Java 实现是GitHub 上的开源项目 jjwt,地址如下:https://github.com/jwtk/jjwt

1.4.2 JWT数据格式

JWT包含三部分数据:

  • Header:头部,通常头部有两部分信息:

    • 声明类型,这里是JWT
    • 加密算法,自定义

    我们会对头部进行Base64Url编码(可解码),得到第一部分数据。

  • Payload:载荷,就是有效数据,在官方文档中(RFC7519),这里给了7个示例信息:

    • iss (issuer):表示签发人
    • exp (expiration time):表示token过期时间
    • sub (subject):主题
    • aud (audience):受众
    • nbf (Not Before):生效时间
    • iat (Issued At):签发时间
    • jti (JWT ID):编号

    这部分也会采用Base64Url编码,得到第二部分数据。

  • Signature:签名,是整个数据的认证信息。一般根据前两步的数据,再加上服务的的密钥secret(密钥保存在服务端,不能泄露给客户端),通过Header中配置的加密算法生成。用于验证整个数据完整和可靠性。

生成的数据格式如下图:

img

注意,这里的数据通过 . 隔开成了三部分,分别对应前面提到的三部分,另外,这里数据是不换行的,图片换行只是为了展示方便而已。

1.4.3 JWT交互流程

流程图:

img

步骤翻译:

  1. 应用程序或客户端向授权服务器请求授权
  2. 获取到授权后,授权服务器会向应用程序返回访问令牌
  3. 应用程序使用访问令牌来访问受保护资源(如API)

因为JWT签发的token中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,这样就完全符合了RESTful的无状态规范。

1.5 JWT 存在的问题

说了这么多,JWT 也不是天衣无缝,由客户端维护登录状态带来的一些问题在这里依然存在,举例如下:

  1. 续签问题,这是被很多人诟病的问题之一,传统的cookie+session的方案天然的支持续签,但是jwt由于服务端不保存用户状态,因此很难完美解决续签问题,如果引入redis,虽然可以解决问题,但是jwt也变得不伦不类了。
  2. 注销问题,由于服务端不再保存用户信息,所以一般可以通过修改secret来实现注销,服务端secret修改后,已经颁发的未过期的token就会认证失败,进而实现注销,不过毕竟没有传统的注销方便。
  3. 密码重置,密码重置后,原本的token依然可以访问系统,这时候也需要强制修改secret。
  4. 基于第2点和第3点,一般建议不同用户取不同secret。

2 整合到SpringBoot

2.1 环境搭建

首先我们来创建一个Spring Boot项目,创建时需要添加Spring Security依赖,创建完成后,添加 jjwt 依赖,完整的pom.xml文件如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

然后在项目中创建一个简单的 User 对象实现 UserDetails 接口,如下:

public class User implements UserDetails {
    private String username;
    private String password;
    private List<GrantedAuthority> authorities;
    public String getUsername() {
        return username;
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return true;
    }
    //省略getter/setter
}

这个就是我们的用户对象,先放着备用,再创建一个HelloController,内容如下:

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        return "hello jwt !";
    }
    @GetMapping("/admin")
    public String admin() {
        return "hello admin !";
    }
}

HelloController 很简单,这里有两个接口,设计是 /hello 接口可以被具有 user 角色的用户访问,而 /admin 接口则可以被具有 admin 角色的用户访问。

2.2 JWT 过滤器配置

接下来提供两个和 JWT 相关的过滤器配置:

  1. 一个是用户登录的过滤器,在用户的登录的过滤器中校验用户是否登录成功,如果登录成功,则生成一个token返回给客户端,登录失败则给前端一个登录失败的提示。
  2. 第二个过滤器则是当其他请求发送来,校验token的过滤器,如果校验成功,就让请求继续执行。

这两个过滤器,我们分别来看,先看第一个:

public class JwtLoginFilter extends AbstractAuthenticationProcessingFilter {
    
  protected JwtLoginFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
    
        super(new AntPathRequestMatcher(defaultFilterProcessesUrl));
        setAuthenticationManager(authenticationManager);
    }
  
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse resp) throws AuthenticationException, IOException, ServletException {
        User user = new ObjectMapper().readValue(req.getInputStream(), User.class);
        return getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()));
    }
  
    @Override
    protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse resp, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
        StringBuffer as = new StringBuffer();
        for (GrantedAuthority authority : authorities) {
            as.append(authority.getAuthority())
                    .append(",");
        }
        String jwt = Jwts.builder()
                .claim("authorities", as)//配置用户角色
                .setSubject(authResult.getName())
                .setExpiration(new Date(System.currentTimeMillis() + 10 * 60 * 1000))
                .signWith(SignatureAlgorithm.HS512,"sang@123")
                .compact();
        resp.setContentType("application/json;charset=utf-8");
        PrintWriter out = resp.getWriter();
        out.write(new ObjectMapper().writeValueAsString(jwt));
        out.flush();
        out.close();
    }
    protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse resp, AuthenticationException failed) throws IOException, ServletException {
        resp.setContentType("application/json;charset=utf-8");
        PrintWriter out = resp.getWriter();
        out.write("登录失败!");
        out.flush();
        out.close();
    }
}

关于这个类,我说如下几点:

  1. 自定义 JwtLoginFilter 继承自 AbstractAuthenticationProcessingFilter,并实现其中的三个默认方法。
  2. attemptAuthentication方法中,我们从登录参数中提取出用户名密码,然后调用AuthenticationManager.authenticate()方法去进行自动校验。
  3. 第二步如果校验成功,就会来到successfulAuthentication回调中,在successfulAuthentication方法中,将用户角色遍历然后用一个 , 连接起来,然后再利用Jwts去生成token,按照代码的顺序,生成过程一共配置了四个参数,分别是用户角色、主题、过期时间以及加密算法和密钥,然后将生成的token写出到客户端。
  4. 第二步如果校验失败就会来到unsuccessfulAuthentication方法中,在这个方法中返回一个错误提示给客户端即可。

再来看第二个token校验的过滤器:

public class JwtFilter extends GenericFilterBean {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) servletRequest;
        String jwtToken = req.getHeader("authorization");
        System.out.println(jwtToken);
        Claims claims = Jwts.parser().setSigningKey("sang@123").parseClaimsJws(jwtToken.replace("Bearer",""))
                .getBody();
        String username = claims.getSubject();//获取当前登录用户名
        List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"));
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null, authorities);
        SecurityContextHolder.getContext().setAuthentication(token);
        filterChain.doFilter(req,servletResponse);
    }
}

关于这个过滤器,我说如下几点:

  1. 首先从请求头中提取出 authorization 字段,这个字段对应的value就是用户的token。
  2. 将提取出来的token字符串转换为一个Claims对象,再从Claims对象中提取出当前用户名和用户角色,创建一个UsernamePasswordAuthenticationToken放到当前的Context中,然后执行过滤链使请求继续执行下去。

如此之后,两个和JWT相关的过滤器就算配置好了。

2.3 Spring Security 配置

接下来我们来配置 Spring Security,如下:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication().withUser("admin")
                .password("123").roles("admin")
                .and()
                .withUser("sang")
                .password("456")
                .roles("user");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/hello").hasRole("user")
                .antMatchers("/admin").hasRole("admin")
                .antMatchers(HttpMethod.POST, "/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtLoginFilter("/login",authenticationManager()),UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new JwtFilter(),UsernamePasswordAuthenticationFilter.class)
                .csrf().disable();
    }
}
  1. 简单起见,这里我并未对密码进行加密,因此配置了NoOpPasswordEncoder的实例。
  2. 简单起见,这里并未连接数据库,我直接在内存中配置了两个用户,两个用户具备不同的角色。
  3. 配置路径规则时, /hello 接口必须要具备 user 角色才能访问, /admin 接口必须要具备 admin 角色才能访问,POST 请求并且是 /login 接口则可以直接通过,其他接口必须认证后才能访问。
  4. 最后配置上两个自定义的过滤器并且关闭掉csrf保护。

2.4 测试

做完这些之后,我们的环境就算完全搭建起来了,接下来启动项目然后在 POSTMAN 中进行测试,如下:

img

登录成功后返回的字符串就是经过 base64url 转码的token,一共有三部分,通过一个 . 隔开,我们可以对第一个 . 之前的字符串进行解码,即Header,如下:

img

再对两个 . 之间的字符解码,即 payload:

img

可以看到,我们设置信息,由于base64并不是加密方案,只是一种编码方案,因此,不建议将敏感的用户信息放到token中。

接下来再去访问 /hello 接口,注意认证方式选择 Bearer Token,Token值为刚刚获取到的值,如下:

img

5. 区别

JWT和OAuth2

  • oauth2有client和scope的概念,jwt没有。如果只是拿来用于颁布token的话,二者没区别。常用的bearer算法oauth、jwt都可以用。应用场景不同而已
  • Spring Cloud 的权限框架就是用的jwt实现的oauth2 。二者没有必然联系
  • Token功能不一样,JWT的token是包含用户基本信息的,然后通过加密的方式生成的字符串,服务器端拿到这个token之后不需要再去查询用户基本信息,解析完token之后就能拿到。想想在微服务架构下,用户服务是一个单独的服务,但是其他服务大部分情况下也会需要用户信息,难道要每次用到都去取一次吗? JWT非常适合微服务。
  • OAuth2用在使用第三方账号登录的情况(比如使用weibo, qq, github登录某个app)。OAuth2是一个相对复杂的协议, 有4种授权模式, 其中的access code模式在实现时可以使用jwt才生成code, 也可以不用. 它们之间没有必然的联系.
  • JWT是用在前后端分离, 需要简单的对后台API进行保护时使用.(前后端分离无session, 频繁传用户密码不安全)
  • JWT是一种认证协议 。JWT提供了一种用于**发布接入令牌(Access Token),**并对发布的签名接入令牌进行验证的方法。 令牌(Token)本身包含了一系列声明,应用程序可以根据这些声明限制用户对资源的访问。
  • OAuth2是一种授权框架。提供了一套详细的授权机制(指导)。用户或应用可以通过公开的或私有的设置,授权第三方应用访问特定资源。

总而言之,Oauth2和jwt是完全不同的两种东西,一个是授权认证的框架,另一种则是认证验证的方式方法(轻量级概念)。OAuth2不像JWT一样是一个严格的标准协议,因此在实施过程中更容易出错。尽管有很多现有的库,但是每个库的成熟度也不尽相同,同样很容易引入各种错误。在常用的库中也很容易发现一些安全漏洞。

OAuth2和Shiro

一、性质不同

1、oauth2 :是OAuth协议的延续版本,但不向前兼容OAuth 2.0(即完全废止了OAuth1.0)。

2、shiro:是一个强大且易用的Java安全框架。

二、语言不同

1、oauth2 :PHP、JavaScript,Java

2、shiro:Java

三、用途不同

1、oauth2 :允许第三方应用代表用户获得访问的权限。

2、shiro:执行身份验证、授权、密码和会话管理。

SpringBoot与中间件

1. WebSocket

1.1 引入

初次接触 WebSocket 的人,都会问同样的问题:我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?

答案很简单,因为 HTTP 协议有一个缺陷:通信只能由客户端发起。

举例来说,我们想了解今天的天气,只能是客户端向服务器发出请求,服务器返回查询结果。HTTP 协议做不到服务器主动向客户端推送信息。

img

这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。我们只能使用“轮询”:每隔一段时候,就发出一个询问,了解服务器有没有新的信息。最典型的场景就是聊天室。

轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。因此,工程师们一直在思考,有没有更好的方法。WebSocket 就是这样发明的。

WebSocket 协议在2008年诞生,2011年成为国际标准。所有浏览器都已经支持了。

它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

img

其他特点包括:

(1)建立在 TCP 协议之上,服务器端的实现比较容易。

(2)与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。

(3)数据格式比较轻量,性能开销小,通信高效。

(4)可以发送文本,也可以发送二进制数据。

(5)没有同源限制,客户端可以与任意服务器通信。

(6)协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。

ws://example.com:80/some/path

img

1.2 整合SpringBoot

实现群聊

引入依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!--前端库集成-->
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>sockjs-client</artifactId>
    <version>1.1.2</version>
</dependency>
<!--兼容浏览器协议-->
<dependency>
  <groupId>org.webjars</groupId>
  <artifactId>stomp-websocket</artifactId>
  <version>2.3.3</version>
</dependency>
<dependency>
  <groupId>org.webjars</groupId>
  <artifactId>jquery</artifactId>
  <version>3.4.1</version>
</dependency>
<!--js文件的定位器-->
<dependency>
  <groupId>org.webjars</groupId>
  <artifactId>webjars-locator-core</artifactId>
</dependency>

实体类

public class Message {
    private String name;
    private String content;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }
}

配置类

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //广播前缀
        registry.enableSimpleBroker("/topic");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        //注册端点,定义前缀为chat的endpoint并开启sockjs的支持
        registry.addEndpoint("/chat").withSockJS();
    }
}

测试类

@Controller
public class GreetingController {
    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Message greeting1(Message message) {
        return message;
    }
}

前端页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>群聊</title>
    <script src="/webjars/jquery/jquery.min.js"></script>
    <script src="/webjars/sockjs-client/sockjs.min.js"></script>
    <script src="/webjars/stomp-websocket/stomp.min.js"></script>
</head>
<body>

<table>
    <tr>
        <td>请输入用户名</td>
        <td><input type="text" id="name"></td>
    </tr>
    <tr>
        <td><input type="button" id="connect" value="连接"></td>
        <td><input type="button" id="disconnect" disabled="disabled" value="断开连接"></td>
    </tr>
</table>
<div id="chat" style="display: none">
    <table>
        <tr>
            <td>请输入聊天内容</td>
            <td><input type="text" id="content"></td>
            <td><input type="button" id="send" value="发送"></td>
        </tr>
    </table>
    <div id="conversation">群聊进行中...</div>
</div>
  
  <script>
    $(function () {
        $("#connect").click(function () {
            connect();
        })
        $("#disconnect").click(function () {
            if (stompClient != null) {
                stompClient.disconnect();
            }
            setConnected(false);
        })

        $("#send").click(function () {
            stompClient.send('/app/hello',{},JSON.stringify({'name':$("#name").val(),'content':$("#content").val()}))
        })
    })

    var stompClient = null;

    function connect() {
        if (!$("#name").val()) {
            return;
        }
        var socket = new SockJS('/chat');
        stompClient = Stomp.over(socket);
        stompClient.connect({}, function (success) {
            setConnected(true);
            stompClient.subscribe('/topic/greetings', function (msg) {
                showGreeting(JSON.parse(msg.body));
            });
        })
    }

    function showGreeting(msg) {
        $("#conversation").append('<div>' + msg.name + ':' + msg.content + '</div>');
    }

    function setConnected(flag) {
        $("#connect").prop("disabled", flag);
        $("#disconnect").prop("disabled", !flag);
        if (flag) {
            $("#chat").show();
        } else {
            $("#chat").hide();
        }
    }
</script>
  
</body>
</html>

实现单聊

引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!--前端库集成-->
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>sockjs-client</artifactId>
    <version>1.1.2</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>jquery</artifactId>
    <version>3.4.1</version>
</dependency>
<!--兼容浏览器协议-->
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>stomp-websocket</artifactId>
    <version>2.3.3</version>
</dependency>
<!--js文件的定位器-->
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>webjars-locator-core</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

实体类

public class Message {
    private String name;
    private String content;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }
}

public class Chat {
    private String from;
    private String content;
    private String to;

    public String getFrom() {
        return from;
    }

    public void setFrom(String from) {
        this.from = from;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public String getTo() {
        return to;
    }

    public void setTo(String to) {
        this.to = to;
    }
}

Security配置类

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("javaboy")
                .password("123")
                .roles("admin")
                .and()
                .withUser("sang")
                .password("123")
                .roles("user");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin().permitAll();
    }
}

socket配置类

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //广播前缀
        registry.enableSimpleBroker("/topic","/queue");
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        //新版SpringBoot要开启连接域
        registry.addEndpoint("/chat").setAllowedOrigins("http://localhost:8080").withSockJS();
    }
}

测试类

@Controller
public class GreetingController {
    @Autowired
    SimpMessagingTemplate simpMessagingTemplate;

    @MessageMapping("/hello")
    public void greeting(Message message) {
        simpMessagingTemplate.convertAndSend("/topic/greetings", message);
    }

    @MessageMapping("/chat")
    public void chat(Principal principal, Chat chat) {
        chat.setFrom(principal.getName());
        simpMessagingTemplate.convertAndSendToUser(chat.getTo(), "/queue/chat", chat);
    }
}

前端页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>群聊</title>
    <script src="/webjars/jquery/jquery.min.js"></script>
    <script src="/webjars/sockjs-client/sockjs.min.js"></script>
    <script src="/webjars/stomp-websocket/stomp.min.js"></script>
</head>
<body>
<input type="button" id="connect" value="连接">
<input type="button" id="disconnect" disabled="disabled" value="断开连接">
<hr>
消息内容:<input type="text" id="content">目标用户:<input type="text" id="to"><input type="button" value="发送" id="send">
<div id="conversation"></div>
<script>
    $(function () {
        $("#connect").click(function () {
            connect();
        })
        $("#disconnect").click(function () {
            if (stompClient != null) {
                stompClient.disconnect();
            }
            setConnected(false);
        })

        $("#send").click(function () {
            stompClient.send('/app/chat', {}, JSON.stringify({
                'to': $("#to").val(),
                'content': $("#content").val()
            }))
        })
    })

    var stompClient = null;

    function connect() {
        var socket = new SockJS('/chat');
        stompClient = Stomp.over(socket);
        stompClient.connect({}, function (success) {
            setConnected(true);
            stompClient.subscribe('/user/queue/chat', function (msg) {
                showGreeting(JSON.parse(msg.body));
            });
        })
    }

    function showGreeting(msg) {
        $("#conversation").append('<div>' + msg.from + ':' + msg.content + '</div>');
    }

    function setConnected(flag) {
        $("#connect").prop("disabled", flag);
        $("#disconnect").prop("disabled", !flag);
        if (flag) {
            $("#chat").show();
        } else {
            $("#chat").hide();
        }
    }
</script>
</body>
</html>

2. 消息队列

2.1 为什么要用消息队列

解耦、异步、削峰

(1)解耦

传统模式:

消息中间件:ActiveMQ、RocketMQ、RabbitMQ、Kafka一些总结和区别1

传统模式的缺点:

  • 系统间耦合性太强,如上图所示,系统A在代码中直接调用系统B和系统C的代码,如果将来D系统接入,系统A还需要修改代码,过于麻烦!

中间件模式:

消息中间件:ActiveMQ、RocketMQ、RabbitMQ、Kafka一些总结和区别2

中间件模式的的优点:

  • 将消息写入消息队列,需要消息的系统自己从消息队列中订阅,从而系统A不需要做任何修改。

(2)异步

传统模式:

消息中间件:ActiveMQ、RocketMQ、RabbitMQ、Kafka一些总结和区别3

传统模式的缺点:

  • 一些非必要的业务逻辑以同步的方式运行,太耗费时间。

中间件模式:

消息中间件:ActiveMQ、RocketMQ、RabbitMQ、Kafka一些总结和区别4

中间件模式的的优点:

  • 将消息写入消息队列,非必要的业务逻辑以异步的方式运行,加快响应速度

(3)削峰

传统模式

消息中间件:ActiveMQ、RocketMQ、RabbitMQ、Kafka一些总结和区别5

传统模式的缺点:

  • 并发量大的时候,所有的请求直接怼到数据库,造成数据库连接异常

中间件模式:

消息中间件:ActiveMQ、RocketMQ、RabbitMQ、Kafka一些总结和区别6

中间件模式的的优点:

  • 系统A慢慢的按照数据库能处理的并发量,从消息队列中慢慢拉取消息。在生产中,这个短暂的高峰期积压是允许的。

2.2 消息队列的弊端

  • 系统可用性降低:本来其他系统只要运行好好的,那系统就是正常的。现在你非要加个消息队列进去,那消息队列挂了,你的系统也会挂。因此,系统可用性降低
  • 系统复杂性增加:要多考虑很多方面的问题,比如一致性问题、如何保证消息不被重复消费,如何保证保证消息可靠传输。因此,需要考虑的东西更多,系统复杂性增大

2.3 如何选用

特性 ActiveMQ RabbitMQ RocketMQ kafka
开发语言 java erlang java scala
单机吞吐量 万级 万级 10万级 10万级
时效性 ms级 us级 ms级 ms级以内
可用性 高(主从架构) 高(主从架构) 非常高(分布式架构) 非常高(分布式架构)
功能特性 成熟的产品,在很多公司得到应用;有较多的文档;各种协议支持较好 基于erlang开发,所以并发能力很强,性能极其好,延时很低;管理界面较丰富 MQ功能比较完备,扩展性佳 只支持主要的MQ功能,像一些消息查询,消息回溯等功能没有提供,毕竟是为大数据准备的,在大数据领域应用广。

(1)中小型软件公司,建议选RabbitMQ.一方面,erlang语言天生具备高并发的特性,而且他的管理界面用起来十分方便。正所谓,成也萧何,败也萧何!他的弊端也在这里,虽然RabbitMQ是开源的,然而国内有几个能定制化开发erlang的程序员呢?所幸,RabbitMQ的社区十分活跃,可以解决开发过程中遇到的bug,这点对于中小型公司来说十分重要。不考虑rocketmq和kafka的原因是,一方面中小型软件公司不如互联网公司,数据量没那么大,选消息中间件,应首选功能比较完备的,所以kafka排除。不考虑rocketmq的原因是,rocketmq是阿里出品,如果阿里放弃维护rocketmq,中小型公司一般抽不出人来进行rocketmq的定制化开发,因此不推荐。

(2)大型软件公司,根据具体使用在rocketMq和kafka之间二选一。一方面,大型软件公司,具备足够的资金搭建分布式环境,也具备足够大的数据量。针对rocketMQ,大型软件公司也可以抽出人手对rocketMQ进行定制化开发,毕竟国内有能力改JAVA源码的人,还是相当多的。至于kafka,根据业务场景选择,如果有日志采集功能,肯定是首选kafka了。具体该选哪个,看使用场景。

2.4 保证是高可用

以rcoketMQ为例,他的集群就有多master 模式、多master多slave异步复制模式、多 master多slave同步双写模式。多master多slave模式部署架构图
image
类似于kafka,只是NameServer集群,在kafka中是用zookeeper代替,都是用来保存和发现master和slave用的。通信过程如下:
Producer 与 NameServer集群中的其中一个节点(随机选择)建立长连接,定期从 NameServer 获取 Topic 路由信息,并向提供 Topic 服务的 Broker Master 建立长连接,且定时向 Broker 发送心跳。Producer 只能将消息发送到 Broker master,但是 Consumer 则不一样,它同时和提供 Topic 服务的 Master 和 Slave建立长连接,既可以从 Broker Master 订阅消息,也可以从 Broker Slave 订阅消息。

2.5 保证消息不被重复消费

分析:这个问题其实换一种问法就是,如何保证消息队列的幂等性?这个问题可以认为是消息队列领域的基本问题。换句话来说,是在考察你的设计能力,这个问题的回答可以根据具体的业务场景来答,没有固定的答案。
回答:先来说一下为什么会造成重复消费?
其实无论是那种消息队列,造成重复消费原因其实都是类似的。正常情况下,消费者在消费消息时候,消费完毕后,会发送一个确认信息给消息队列,消息队列就知道该消息被消费了,就会将该消息从消息队列中删除。只是不同的消息队列发送的确认信息形式不同,例如RabbitMQ是发送一个ACK确认消息,RocketMQ是返回一个CONSUME_SUCCESS成功标志,kafka实际上有个offset的概念,简单说一下(如果还不懂,出门找一个kafka入门到精通教程),就是每一个消息都有一个offset,kafka消费过消息后,需要提交offset,让消息队列知道自己已经消费过了。那造成重复消费的原因?,就是因为网络传输等等故障,确认信息没有传送到消息队列,导致消息队列不知道自己已经消费过该消息了,再次将该消息分发给其他的消费者。
如何解决?这个问题针对业务场景来答分以下几点
  (1)比如,你拿到这个消息做数据库的insert操作。那就容易了,给这个消息做一个唯一主键,那么就算出现重复消费的情况,就会导致主键冲突,避免数据库出现脏数据。
  (2)再比如,你拿到这个消息做redis的set的操作,那就容易了,不用解决,因为你无论set几次结果都是一样的,set操作本来就算幂等操作。
  (3)如果上面两种情况还不行,上大招。准备一个第三方介质,来做消费记录。以redis为例,给消息分配一个全局id,只要消费过该消息,将<id,message>以K-V形式写入redis。那消费者开始消费前,先去redis中查询有没消费记录即可。

2.6 消费的可靠性传输

分析:我们在使用消息队列的过程中,应该做到消息不能多消费,也不能少消费。如果无法做到可靠性传输,可能给公司带来千万级别的财产损失。

回答:其实这个可靠性传输,每种MQ都要从三个角度来分析:生产者弄丢数据、消息队列弄丢数据、消费者弄丢数据

以RabbitMQ为例

(1)生产者丢数据
从生产者弄丢数据这个角度来看,RabbitMQ提供transaction和confirm模式来确保生产者不丢消息。

transaction机制就是说,发送消息前,开启事物(channel.txSelect()),然后发送消息,如果发送过程中出现什么异常,事物就会回滚(channel.txRollback()),如果发送成功则提交事物(channel.txCommit())。

然而缺点就是吞吐量下降了。因此,按照博主的经验,生产上用confirm模式的居多。一旦channel进入confirm模式,所有在该信道上面发布的消息都将会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列之后,rabbitMQ就会发送一个Ack给生产者(包含消息的唯一ID),这就使得生产者知道消息已经正确到达目的队列了.如果rabiitMQ没能处理该消息,则会发送一个Nack消息给你,你可以进行重试操作

(2)消息队列丢数据

处理消息队列丢数据的情况,一般是开启持久化磁盘的配置。这个持久化配置可以和confirm机制配合使用,你可以在消息持久化磁盘后,再给生产者发送一个Ack信号。这样,如果消息持久化磁盘之前,rabbitMQ阵亡了,那么生产者收不到Ack信号,生产者会自动重发。
那么如何持久化呢,这里顺便说一下吧,其实也很容易,就下面两步

  • 将queue的持久化标识durable设置为true,则代表是一个持久的队列

  • 发送消息的时候将deliveryMode=2

    这样设置以后,rabbitMQ就算挂了,重启后也能恢复数据

(3)消费者丢数据

消费者丢数据一般是因为采用了自动确认消息模式。这种模式下,消费者会自动确认收到信息。这时rahbitMQ会立即将消息删除,这种情况下如果消费者出现异常而没能处理该消息,就会丢失该消息。
至于解决方案,采用手动确认消息即可。

2.7 保证消息顺序性

回答:针对这个问题,通过某种算法,将需要保持先后顺序的消息放到同一个消息队列中(kafka中就是partition,rabbitMq中就是queue)。然后只用一个消费者去消费该队列。

有的人会问:那如果为了吞吐量,有多个消费者去消费怎么办?
这个问题,没有固定回答的套路。比如我们有一个微博的操作,发微博、写评论、删除微博,这三个异步操作。如果是这样一个业务场景,那只要重试就行。比如你一个消费者先执行了写评论的操作,但是这时候,微博都还没发,写评论一定是失败的,等一段时间。等另一个消费者,先执行写评论的操作后,再执行,就可以成功。

总之,针对这个问题,我的观点是保证入队有序就行,出队以后的顺序交给消费者自己去保证,没有固定套路。

3. ActiveMQ

3.1 安装

下载最新的压缩包

image-20210526113352279

下载完成后在bin目录下执行./activemq start即可

image-20210526113812627

账号密码都是admin

image-20210526113848366

3.2 整合到SpringBoot

引入依赖

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

配置文件

spring.activemq.broker-url=tcp://127.0.0.1:61616
spring.activemq.packages.trust-all=true
spring.activemq.user=adminx
spring.activemq.password=admin

启动类

@SpringBootApplication
public class ActivemqApplication {

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

    @Bean
    Queue queue() {
        return new ActiveMQQueue("hello.javaboy");
    }
}

实体类

public class Message implements Serializable {
    private String content;
    private Date sendDate;

    public String getContent() {
        return content;
    }

    @Override
    public String toString() {
        return "Message{" +
                "content='" + content + '\'' +
                ", sendDate=" + sendDate +
                '}';
    }

    public void setContent(String content) {
        this.content = content;
    }

    public Date getSendDate() {
        return sendDate;
    }

    public void setSendDate(Date sendDate) {
        this.sendDate = sendDate;
    }
}

配置组件

@Component
public class JmsComponent {
    @Autowired
    JmsMessagingTemplate jmsMessagingTemplate;
    @Autowired
    Queue queue;

    public void send(Message message) {
        jmsMessagingTemplate.convertAndSend(this.queue, message);
    }

    @JmsListener(destination = "hello.javaboy")
    public void receive(Message msg) {
        System.out.println(msg);
    }
}

4. RabbitMQ

1. 安装

安装参考Linux中的教程

2.整合到SpringBoot

引入依赖

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

配置文件

spring.rabbitmq.host=192.168.66.131
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
spring.rabbitmq.port=32771

RabbitMQ 有四种交换机模式:

  • Direct Pattern (此模式不需要配置交换机)
  • Fanout Pattern ( 类似于广播一样,将消息发送给和他绑定的队列 )
  • Topic Pattern ( 绑定交换机时可以做匹配。 #:表示零个或多个单词。*:表示一个单词 )
  • Header Pattern ( 带有参数的匹配规则 )

2.1 Direct模式

直连交换机是一种带路由功能的交换机,一个队列会和一个交换机绑定,除此之外再绑定一个routing_key,当消息被发送的时候,需要指定一个binding_key,这个消息被送达交换机的时候,就会被这个交换机送到指定的队列里面去。同样的一个binding_key也是支持应用到多个队列中的。这样当一个交换机绑定多个队列,就会被送到对应的队列去处理。

img

适用场景:有优先级的任务,根据任务的优先级把消息发送到对应的队列,这样可以指派更多的资源去处理高优先级的队列。

配置类

@Configuration
public class RabbitDirectConfig {
    public final static String DIRECTNAME = "javaboy-direct";

    @Bean
    Queue queue() {
        return new Queue("hello.javaboy");
    }
    @Bean
    DirectExchange directExchange() {
        return new DirectExchange(DIRECTNAME, true, false);
    }

    @Bean
    Binding binding() {
        return BindingBuilder.bind(queue()).to(directExchange()).with("direct");
    }
}

接收机

@Component
public class DirectReceiver {
    @RabbitListener(queues = "hello.javaboy")
    public void handler1(String msg) {
        System.out.println("handler1>>>"+msg);
    }
}

测试类

@RunWith(SpringRunner.class)
@SpringBootTest
public class RabbitmqApplicationTests {

    @Autowired
    RabbitTemplate rabbitTemplate;
    @Test
    public void contextLoads() {
        rabbitTemplate.convertAndSend("hello.javaboy", "hello javaboy!hahaha");
    }
}

2.2 Fanout模式

扇形交换机是最基本的交换机类型,它所能做的事情非常简单———广播消息。扇形交换机会把能接收到的消息全部发送给绑定在自己身上的队列。因为广播不需要“思考”,所以扇形交换机处理消息的速度也是所有的交换机类型里面最快的。

img

配置类

@Configuration
public class RabbitFanoutConfig {
    public static final String FANOUTNAME = "javaboy-fanout";
    @Bean
    Queue queueOne() {
        return new Queue("queue-one");
    }

    @Bean
    Queue queueTwo() {
        return new Queue("queue-two");
    }

    @Bean
    FanoutExchange fanoutExchange() {
        return new FanoutExchange(FANOUTNAME, true, false);
    }

    @Bean
    Binding bindingOne() {
        return BindingBuilder.bind(queueOne()).to(fanoutExchange());
    }
    @Bean
    Binding bindingTwo() {
        return BindingBuilder.bind(queueTwo()).to(fanoutExchange());
    }
}

接收机

@Component
public class FanoutReceiver {
    @RabbitListener(queues = "queue-one")
    public void handler1(String msg) {
        System.out.println("FanoutReceiver:handler1:" + msg);
    }

    @RabbitListener(queues = "queue-two")
    public void handler2(String msg) {
        System.out.println("FanoutReceiver:handler2:" + msg);
    }
}

测试类

@Test
public void test1() {
    rabbitTemplate.convertAndSend(RabbitFanoutConfig.FANOUTNAME,null,"hello fanout!");
}

2.3 Topic模式

直连交换机的routing_key方案非常简单,如果我们希望一条消息发送给多个队列,那么这个交换机需要绑定上非常多的routing_key,假设每个交换机上都绑定一堆的routing_key连接到各个队列上。那么消息的管理就会异常地困难。

所以RabbitMQ提供了一种主题交换机,发送到主题交换机上的消息需要携带指定规则的routing_key,主题交换机会根据这个规则将数据发送到对应的(多个)队列上。

主题交换机的routing_key需要有一定的规则,交换机和队列的binding_key需要采用*.#.*.....的格式,每个部分用.分开,其中:

  • *表示一个单词
  • #表示任意数量(零个或多个)单词。

假设有一条消息的routing_keyfast.rabbit.white,那么带有这样binding_key的几个队列都会接收这条消息:

  • fast..
  • ..white
  • fast.#
  • ……

这个图是网上找的,感觉对主题交换机的描述比较到位:

img

主题交换机

当一个队列的绑定键为#的时候,这个队列将会无视消息的路由键,接收所有的消息

配置类

@Configuration
public class RabbitTopicConfig {
    public static final String TOPICNAME = "javaboy-topic";

    @Bean
    TopicExchange topicExchange() {
        return new TopicExchange(TOPICNAME, true, false);
    }

    @Bean
    Queue xiaomi() {
        return new Queue("xiaomi");
    }

    @Bean
    Queue huawei() {
        return new Queue("huawei");
    }

    @Bean
    Queue phone() {
        return new Queue("phone");
    }

    @Bean
    Binding xiaomiBinding() {
        return BindingBuilder.bind(xiaomi()).to(topicExchange()).with("xiaomi.#");
    }

    @Bean
    Binding huaweiBinding() {
        return BindingBuilder.bind(huawei()).to(topicExchange()).with("huawei.#");
    }
    @Bean
    Binding phoneBinding() {
        return BindingBuilder.bind(phone()).to(topicExchange()).with("#.phone.#");
    }
}

接收机

@Component
public class TopicReceiver {
    @RabbitListener(queues = "xiaomi")
    public void handler1(String msg) {
        System.out.println("TopicReceiver:handler1:" + msg);
    }

    @RabbitListener(queues = "huawei")
    public void handler2(String msg) {
        System.out.println("TopicReceiver:handler2:" + msg);
    }

    @RabbitListener(queues = "phone")
    public void handler3(String msg) {
        System.out.println("TopicReceiver:handler3:" + msg);
    }
}

测试类

@Test
public void test2() {
    rabbitTemplate.convertAndSend(RabbitTopicConfig.TOPICNAME, "xiaomi.news", "小米新闻");
    rabbitTemplate.convertAndSend(RabbitTopicConfig.TOPICNAME, "vivo.phone", "vivo 手机");
    rabbitTemplate.convertAndSend(RabbitTopicConfig.TOPICNAME, "huawei.phone", "华为手机");
}

2.4 Header模式

首部交换机是忽略routing_key的一种路由方式。路由器和交换机路由的规则是通过Headers信息来交换的,这个有点像HTTPHeaders。将一个交换机声明成首部交换机,绑定一个队列的时候,定义一个Hash的数据结构,消息发送的时候,会携带一组hash数据结构的信息,当Hash的内容匹配上的时候,消息就会被写入队列。

绑定交换机和队列的时候,Hash结构中要求携带一个键“x-match”,这个键的Value可以是any或者all,这代表消息携带的Hash是需要全部匹配(all),还是仅匹配一个键(any)就可以了。相比直连交换机,首部交换机的优势是匹配的规则不被限定为字符串(string)

配置类

@Configuration
public class RabbitHeaderConfig {
    public static final String HEADERNAME = "javaboy-header";

    @Bean
    HeadersExchange headersExchange() {
        return new HeadersExchange(HEADERNAME, true, false);
    }

    @Bean
    Queue queueName() {
        return new Queue("name-queue");
    }

    @Bean
    Queue queueAge() {
        return new Queue("age-queue");
    }

    @Bean
    Binding bindingName() {
        Map<String, Object> map = new HashMap<>();
        map.put("name", "javaboy");
        return BindingBuilder.bind(queueName()).to(headersExchange()).whereAny(map).match();
    }

    @Bean
    Binding bindingAge() {
        return BindingBuilder.bind(queueAge()).to(headersExchange()).where("age").exists();
    }
}

接收机

@Component
public class HeaderReceiver {
    @RabbitListener(queues = "name-queue")
    public void handler1(byte[] msg) {
        System.out.println("HeaderReceiver:handler1:" + new String(msg, 0, msg.length));
    }

    @RabbitListener(queues = "age-queue")
    public void handler2(byte[] msg) {
        System.out.println("HeaderReceiver:handler2:" + new String(msg, 0, msg.length));
    }
}

测试类

@Test
public void test3() {
    Message nameMsg = MessageBuilder.withBody("hello javaboy !".getBytes()).setHeader("name","江南一点雨").build();
    Message ageMsg = MessageBuilder.withBody("hello 99 !".getBytes()).setHeader("age","99").build();
    rabbitTemplate.send(RabbitHeaderConfig.HEADERNAME, null, ageMsg);
    rabbitTemplate.send(RabbitHeaderConfig.HEADERNAME, null, nameMsg);
}

SpringBoot企业级开发

1. 邮件

1.1 邮件协议

我们经常会听到各种各样的邮件协议,比如 SMTP、POP3、IMAP ,那么这些协议有什么作用,有什么区别?我们先来讨论一下这个问题。

SMTP 是一个基于 TCP/IP 的应用层协议,江湖地位有点类似于 HTTP,SMTP 服务器默认监听的端口号为 25 。看到这里,小伙伴们可能会想到既然 SMTP 协议是基于 TCP/IP 的应用层协议,那么我是不是也可以通过 Socket 发送一封邮件呢?回答是肯定的。

生活中我们投递一封邮件要经过如下几个步骤:

  1. 深圳的小王先将邮件投递到深圳的邮局
  2. 深圳的邮局将邮件运送到上海的邮局
  3. 上海的小张来邮局取邮件

这是一个缩减版的生活中邮件发送过程。这三个步骤可以分别对应我们的邮件发送过程,假设从 aaa@qq.com 发送邮件到 111@163.com

  1. aaa@qq.com 先将邮件投递到腾讯的邮件服务器
  2. 腾讯的邮件服务器将我们的邮件投递到网易的邮件服务器
  3. 111@163.com 登录网易的邮件服务器查看邮件

邮件投递大致就是这个过程,这个过程就涉及到了多个协议,我们来分别看一下。

SMTP 协议全称为 Simple Mail Transfer Protocol,译作简单邮件传输协议,它定义了邮件客户端软件于 SMTP 服务器之间,以及 SMTP 服务器与 SMTP 服务器之间的通信规则。

也就是说 aaa@qq.com 用户先将邮件投递到腾讯的 SMTP 服务器这个过程就使用了 SMTP 协议,然后腾讯的 SMTP 服务器将邮件投递到网易的 SMTP 服务器这个过程也依然使用了 SMTP 协议,SMTP 服务器就是用来收邮件。

而 POP3 协议全称为 Post Office Protocol ,译作邮局协议,它定义了邮件客户端与 POP3 服务器之间的通信规则,那么该协议在什么场景下会用到呢?当邮件到达网易的 SMTP 服务器之后, 111@163.com 用户需要登录服务器查看邮件,这个时候就该协议就用上了:邮件服务商都会为每一个用户提供专门的邮件存储空间,SMTP 服务器收到邮件之后,就将邮件保存到相应用户的邮件存储空间中,如果用户要读取邮件,就需要通过邮件服务商的 POP3 邮件服务器来完成。

1.2 邮件发送

项目创建

接下来,我们就可以创建项目了,Spring Boot 中,对于邮件发送提供了自动配置类,开发者只需要加入相关依赖,然后配置一下邮箱的基本信息,就可以发送邮件了。

  • 首先创建一个 Spring Boot 项目,引入邮件发送依赖:

img

创建完成后,项目依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
  • 配置邮箱基本信息

项目创建成功后,接下来在 application.properties 中配置邮箱的基本信息:

spring.mail.host=smtp.qq.com
spring.mail.port=587
spring.mail.username=xxxx@xxx.com
spring.mail.password=xxxx
spring.mail.default-encoding=UTF-8
spring.mail.properties.mail.smtp.socketFactoryClass=javax.net.ssl.SSLSocketFactory
spring.mail.properties.mail.debug=true

配置含义分别如下:

  • 配置 SMTP 服务器地址
  • SMTP 服务器的端口
  • 配置邮箱用户名
  • 配置密码,注意,不是真正的密码,而是刚刚申请到的授权码
  • 默认的邮件编码
  • 配饰 SSL 加密工厂
  • 表示开启 DEBUG 模式,这样,邮件发送过程的日志会在控制台打印出来,方便排查错误

如果不知道 smtp 服务器的端口或者地址的的话,可以参考 腾讯的邮箱文档

做完这些之后,Spring Boot 就会自动帮我们配置好邮件发送类,相关的配置在 org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration 类中,部分源码如下:

@Configuration
@ConditionalOnClass({ MimeMessage.class, MimeType.class, MailSender.class })
@ConditionalOnMissingBean(MailSender.class)
@Conditional(MailSenderCondition.class)
@EnableConfigurationProperties(MailProperties.class)
@Import({ MailSenderJndiConfiguration.class, MailSenderPropertiesConfiguration.class })
public class MailSenderAutoConfiguration {
}

从这段代码中,可以看到,导入了另外一个配置 MailSenderPropertiesConfiguration 类,这个类中,提供了邮件发送相关的工具类:

@Configuration
@ConditionalOnProperty(prefix = "spring.mail", name = "host")
class MailSenderPropertiesConfiguration {
        private final MailProperties properties;
        MailSenderPropertiesConfiguration(MailProperties properties) {
                this.properties = properties;
        }
        @Bean
        @ConditionalOnMissingBean
        public JavaMailSenderImpl mailSender() {
                JavaMailSenderImpl sender = new JavaMailSenderImpl();
                applyProperties(sender);
                return sender;
        }
}

可以看到,这里创建了一个 JavaMailSenderImpl 的实例, JavaMailSenderImplJavaMailSender 的一个实现,我们将使用 JavaMailSenderImpl 来完成邮件的发送工作。

简单邮件

简单邮件就是指邮件内容是一个普通的文本文档:

@Autowired
JavaMailSender javaMailSender;
@Test
public void sendSimpleMail() {
    SimpleMailMessage message = new SimpleMailMessage();
    message.setSubject("这是一封测试邮件");
    message.setFrom("1510161612@qq.com");
    message.setTo("25xxxxx755@qq.com");
    message.setCc("37xxxxx37@qq.com");
    message.setBcc("14xxxxx098@qq.com");
    message.setSentDate(new Date());
    message.setText("这是测试邮件的正文");
    javaMailSender.send(message);
}

从上往下,代码含义分别如下:

  • 构建一个邮件对象
  • 设置邮件主题
  • 设置邮件发送者
  • 设置邮件接收者,可以有多个接收者
  • 设置邮件抄送人,可以有多个抄送人
  • 设置隐秘抄送人,可以有多个
  • 设置邮件发送日期
  • 设置邮件的正文
  • 发送邮件

带附件的邮件

邮件的附件可以是图片,也可以是普通文件,都是支持的。

@Test
public void sendAttachFileMail() throws MessagingException {
    MimeMessage mimeMessage = javaMailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(mimeMessage,true);
    helper.setSubject("这是一封测试邮件");
    helper.setFrom("1510161612@qq.com");
    helper.setTo("25xxxxx755@qq.com");
    helper.setCc("37xxxxx37@qq.com");
    helper.setBcc("14xxxxx098@qq.com");
    helper.setSentDate(new Date());
    helper.setText("这是测试邮件的正文");
    helper.addAttachment("javaboy.jpg",new File("C:\\Users\\sang\\Downloads\\javaboy.png"));
    javaMailSender.send(mimeMessage);
}

注意这里在构建邮件对象上和前文有所差异,这里是通过 javaMailSender 来获取一个复杂邮件对象,然后再利用 MimeMessageHelper 对邮件进行配置,MimeMessageHelper 是一个邮件配置的辅助工具类,创建时候的 true 表示构建一个 multipart message 类型的邮件,有了 MimeMessageHelper 之后,我们针对邮件的配置都是由 MimeMessageHelper 来代劳。最后通过 addAttachment 方法来添加一个附件。

图片资源的邮件

图片资源和附件有什么区别呢?图片资源是放在邮件正文中的,即一打开邮件,就能看到图片。但是一般来说,不建议使用这种方式,一些公司会对邮件内容的大小有限制(因为这种方式是将图片一起发送的)。

@Test
public void sendImgResMail() throws MessagingException {
    MimeMessage mimeMessage = javaMailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);
    helper.setSubject("这是一封测试邮件");
    helper.setFrom("1510161612@qq.com");
    helper.setTo("25xxxxx755@qq.com");
    helper.setCc("37xxxxx37@qq.com");
    helper.setBcc("14xxxxx098@qq.com");
    helper.setSentDate(new Date());
    helper.setText("<p>hello 大家好,这是一封测试邮件,这封邮件包含两种图片,分别如下</p><p>第一张图片:</p><img src='cid:p01'/><p>第二张图片:</p><img src='cid:p02'/>",true);
    helper.addInline("p01",new FileSystemResource(new File("C:\\Users\\sang\\Downloads\\javaboy.png")));
    helper.addInline("p02",new FileSystemResource(new File("C:\\Users\\sang\\Downloads\\javaboy2.png")));
    javaMailSender.send(mimeMessage);
}

这里的邮件 text 是一个 HTML 文本,里边涉及到的图片资源先用一个占位符占着,setText 方法的第二个参数 true 表示第一个参数是一个 HTML 文本。setText 之后,再通过 addInline 方法来添加图片资源。

1.3 Freemarker邮件

首先需要引入 Freemarker 依赖:

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

然后在 resources/templates 目录下创建一个 mail.ftl 作为邮件发送模板:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<p>hello 欢迎加入 xxx 大家庭,您的入职信息如下:</p>
<table border="1">
    <tr>
        <td>姓名</td>
        <td>${username}</td>
    </tr>
    <tr>
        <td>工号</td>
        <td>${num}</td>
    </tr>
    <tr>
        <td>薪水</td>
        <td>${salary}</td>
    </tr>
</table>
<div style="color: #ff1a0e">一起努力创造辉煌</div>
</body>
</html>

接下来,将邮件模板渲染成 HTML ,然后发送即可。

@Test
public void sendFreemarkerMail() throws MessagingException, IOException, TemplateException {
    MimeMessage mimeMessage = javaMailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);
    helper.setSubject("这是一封测试邮件");
    helper.setFrom("1510161612@qq.com");
    helper.setTo("25xxxxx755@qq.com");
    helper.setCc("37xxxxx37@qq.com");
    helper.setBcc("14xxxxx098@qq.com");
    helper.setSentDate(new Date());
    //构建 Freemarker 的基本配置
    Configuration configuration = new Configuration(Configuration.VERSION_2_3_0);
    // 配置模板位置
    ClassLoader loader = MailApplication.class.getClassLoader();
    configuration.setClassLoaderForTemplateLoading(loader, "templates");
    //加载模板
    Template template = configuration.getTemplate("mail.ftl");
    User user = new User();
    user.setUsername("javaboy");
    user.setNum(1);
    user.setSalary((double) 99999);
    StringWriter out = new StringWriter();
    //模板渲染,渲染的结果将被保存到 out 中 ,将out 中的 html 字符串发送即可
    template.process(user, out);
    helper.setText(out.toString(),true);
    javaMailSender.send(mimeMessage);
}

需要注意的是,虽然引入了 Freemarker 的自动化配置,但是我们在这里是直接 new Configuration 来重新配置 Freemarker 的,所以 Freemarker 默认的配置这里不生效,因此,在填写模板位置时,值为 templates

1.4 Thymeleaf邮件

推荐在 Spring Boot 中使用 Thymeleaf 来构建邮件模板。因为 Thymeleaf 的自动化配置提供了一个 TemplateEngine,通过 TemplateEngine 可以方便的将 Thymeleaf 模板渲染为 HTML ,同时,Thymeleaf 的自动化配置在这里是继续有效的 。

首先,引入 Thymeleaf 依赖:

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

然后,创建 Thymeleaf 邮件模板:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<p>hello 欢迎加入 xxx 大家庭,您的入职信息如下:</p>
<table border="1">
    <tr>
        <td>姓名</td>
        <td th:text="${username}"></td>
    </tr>
    <tr>
        <td>工号</td>
        <td th:text="${num}"></td>
    </tr>
    <tr>
        <td>薪水</td>
        <td th:text="${salary}"></td>
    </tr>
</table>
<div style="color: #ff1a0e">一起努力创造辉煌</div>
</body>
</html>

接下来发送邮件:

@Autowired
TemplateEngine templateEngine;

@Test
public void sendThymeleafMail() throws MessagingException {
    MimeMessage mimeMessage = javaMailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);
    helper.setSubject("这是一封测试邮件");
    helper.setFrom("1510161612@qq.com");
    helper.setTo("25xxxxx755@qq.com");
    helper.setCc("37xxxxx37@qq.com");
    helper.setBcc("14xxxxx098@qq.com");
    helper.setSentDate(new Date());
    Context context = new Context();
    context.setVariable("username", "javaboy");
    context.setVariable("num","000001");
    context.setVariable("salary", "99999");
    String process = templateEngine.process("mail.html", context);
    helper.setText(process,true);
    javaMailSender.send(mimeMessage);
}

SpringBoot应用监控

应用监控是我们在生产环境下一个非常重要的东西,运维人员不可能 24 小时盯着应用,应用挂了及时解决,这不现实。我们需要能够实时掌握应用的运行数据,以便提早发现问题,同时在应用挂掉的时候还能够自动报警,这样才能解放开发人员。

Spring Boot 中也提供了生产级的应用监控方案,对于单体应用、微服务应用都有相应的解决方案,今天松哥就想来和大家捋一捋 Spring Boot 中的应用监控方案都有哪些。

首先我们来捋一下应用监控都需要哪些东西?其实就两点:

  • 信息采集器
  • 数据可视化 UI

信息采集器会收集应用的健康、审计、指标、HTTP 请求等信息,并将之暴露出来,数据可视化 UI 则会通过仪表盘、图形等展示这些数据,并对数据进行分析、报警等处理。我们分别来看。

1. Spring Boot Actuator

1.1 简介

在 Spring Boot 项目中,我们使用的信息采集器主要就是 Spring Boot Actuator,这个模块由 Spring Boot 官方提供,它包含了许多生产级别的功能,例如健康检查、审计、指标收集、HTTP 请求追踪等,Spring Boot Actuator 将这些信息收集起来后,通过 HTTP 和 JMX 两种方式暴露给外部模块。例如 Spring Boot Actuator 通过 /health 端点(endpoints)提供了应用的健康信息,开发者只需要访问该端点就可以看到应用的健康信息,但是这些端点返回的数据是 JSON 格式的,不方便查看,也不方便分析,所以一般情况下,Spring Boot Actuator 都是和一些外部模块一起使用。

Spring Boot Actuator 支持的端点主要有如下一些:

img

如果是 Web 应用,则再次基础上还支持如下端点:

img

提到 Spring Boot Actuator,就还有一个东西需要和大家介绍,那就是 Micrometer,从 Spring Boot2.0 开始,Actuator 底层改为了 Micrometer。

当我们在一个 Spring Boot 项目中引入 Actuator 依赖之后,我们会发现它里边包含了 Micrometer:

img

Micrometer 为 Java 平台上的性能数据收集提供了一个通用的 API,应用程序只需要使用 Micrometer 的通用 API 来收集性能指标即可,而 Micrometer 则会负责完成与不同监控系统的适配工作,类似于一个 Adapter,有了这个 Adapter,切换监控系统就变得非常容易。同时 Micrometer 还支持推送数据到多个不同的监控系统。

而 Spring Boot Actuator 使用 Micrometer 与外部应用监视系统进行集成,这样一来,开发者只需要稍微配置一下就可以使其和外部应用监视系统进行整合了。Micrometer 支持的监控系统有:

  • AppOptics
  • Atlas
  • Datadog
  • Dynatrace
  • Elastic
  • Ganglia
  • Graphite
  • Humio
  • Influx
  • JMX
  • KairosDB
  • New Relic
  • Prometheus
  • SignalFx
  • Simple (in-memory)
  • StatsD
  • Wavefront

1.2 整合到SpringBoot

基础用法

引入依赖

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

启动后可以看到

image-20210527112349779

配置文件

management.endpoints.web.exposure.include=*
# 开启优雅的关闭
management.endpoints.shutdown.enabled=true

#SecurityConfig
spring.security.user.name=javaboy
spring.security.user.password=123
spring.security.user.roles=ADMIN

Security配置

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.requestMatcher(EndpointRequest.toAnyEndpoint())
                .authorizeRequests()
                .anyRequest().hasRole("ADMIN")
                .and()
                .httpBasic().and().csrf().disable();
    }
}

配置路径映射

# 以/开头
management.endpoints.web.base-path=/
# bs代替beans
management.endpoints.web.path-mapping.beans=bs

支持跨域

management.endpoints.web.cors.allowed-origins=http://localhost:8888
management.endpoints.web.cors.allowed-methods=GET,POST

健康信息

management.endpoint.health.show-details=when_authorized
management.endpoint.health.roles=ADMIN
management.endpoint.health.status.order=FATAL,DOWN,OUT_OF_SERVICE,UP,UNKNOWN
management.endpoint.health.status.http-mapping.FATAL=503

自定义健康

@Component
public class JavaboyHealth implements HealthIndicator {
    @Override
    public Health health() {
 //       return Health.status("FATAL").withDetail("msg","发现严重问题").build();
       return Health.up().withDetail("msg","一切正常...").build();
    }
}

应用信息

info.app.encoding=@project.build.sourceEncoding@
info.app.java.source=@java.version@
info.app.java.target=@java.version@
info.author.name=lucifer
info.author.email=xx@qq.com

或者在配置类中定义

@Component
public class AppInfo implements InfoContributor {
    @Override
    public void contribute(Info.Builder builder) {
        Map<String, String> link = new HashMap<>();
        link.put("site", "www.javaboy.org");
        link.put("site-2", "www.itboyhub.com");
        builder.withDetail("link", link);
    }
}

git相关

引入依赖

<plugin>
    <groupId>pl.project13.maven</groupId>
    <artifactId>git-commit-id-plugin</artifactId>
</plugin>

image-20210527115729859

management.info.git.mode=full

构建信息

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>build-info</goal>
            </goals>
        </execution>
    </executions>
</plugin>

image-20210527115712060

1.3 Admin

这个算是 Spring Boot 中最最正宗的应用监控可视化工具了,看名字就知道有多正宗,当我们创建一个 Spring Boot 项目时,选择依赖时候就有这个选项:

img

如果是单体应用很多人可能会选择 Spring Boot Admin 作为监控数据可视化工具,不过它也支持微服务应用的(可以通过 Eureka、Consul 等注册中心获取应用信息),只不过在微服务中,我们可能会更多的选择 Grafana+Prometheus 组合。

Spring Boot Admin 主要包含如下功能:

  • 显示应用健康信息。
  • 显示应用运行的详细信息,例如 JVM 和内存指标、数据源指标、缓存指标等等。
  • 显示应用的构建信息。
  • 查看 JVM 系统和环境属性
  • 查看 Spring Boot 配置属性
  • 支持 Spring Cloud 中的端点刷新功能 /refresh-endpoint
  • 方便的日志级别管理功能
  • 可以与 JMX-beans 进行交互
  • 查看 Thread dump
  • 查看 http 请求
  • 查看计划任务
  • 查看和删除活动会话
  • 查看 Flyway/Liquibase 数据库迁移
  • 下载 heapdump
  • 状态更改通知

可以看到,Spring Boot Admin 不仅仅是将 Actuator 接口中的数据进行可视化,还在此基础上提供了分析、报警等功能。

Spring Boot Admin 的显示界面如下:

img

原本客户端上引入依赖

<dependency>
    <groupId>de.codecentric</groupId>
    <artifactId>spring-boot-admin-starter-client</artifactId>
</dependency>

这里服务端引入依赖

<dependency>
    <groupId>de.codecentric</groupId>
    <artifactId>spring-boot-admin-starter-server</artifactId>
</dependency>

启动类

@SpringBootApplication
@EnableAdminServer
public class AdminApplication {

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

}

服务器文件配置

server.port=8081
spring.boot.admin.instance-auth.default-user-name=javaboy
spring.boot.admin.instance-auth.default-password=123

客户端文件配置

spring.boot.admin.client.url=http://localhost:8081

1.4 邮件报警

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>
spring.mail.host=smtp.qq.com
spring.mail.port=465
spring.mail.username=1510161612@qq.com
spring.mail.password=laikremaxeyqfgbj
spring.mail.default-encoding=utf-8
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
spring.mail.properties.mail.debug=true

spring.boot.admin.notify.mail.from=1510161612@qq.com
spring.boot.admin.notify.mail.to=1470249098@qq.com
#状态发生变化就发
spring.boot.admin.notify.mail.ignore-changes=

SpringBoot打包发布

1. 可执行Jar

Spring Boot 中默认打包成的 jar 叫做 可执行 jar,这种 jar 不同于普通的 jar,普通的 jar 不可以通过 java -jar xxx.jar 命令执行,普通的 jar 主要是被其他应用依赖,Spring Boot 打成的 jar 可以执行,但是不可以被其他的应用所依赖,即使强制依赖,也无法获取里边的类。但是可执行 jar 并不是 Spring Boot 独有的,Java 工程本身就可以打包成可执行 jar 。

有的小伙伴可能就有疑问了,既然同样是执行 mvn package 命令进行项目打包,为什么 Spring Boot 项目就打成了可执行 jar ,而普通项目则打包成了不可执行 jar 呢?

这我们就不得不提 Spring Boot 项目中一个默认的插件配置 spring-boot-maven-plugin ,这个打包插件存在 5 个方面的功能,从插件命令就可以看出:

img

五个功能分别是:

  • build-info:生成项目的构建信息文件 build-info.properties
  • repackage:这个是默认 goal,在 mvn package 执行之后,这个命令再次打包生成可执行的 jar,同时将 mvn package 生成的 jar 重命名为 *.origin
  • run:这个可以用来运行 Spring Boot 应用
  • start:这个在 mvn integration-test 阶段,进行 Spring Boot 应用生命周期的管理
  • stop:这个在 mvn integration-test 阶段,进行 Spring Boot 应用生命周期的管理

这里功能,默认情况下使用就是 repackage 功能,其他功能要使用,则需要开发者显式配置

2. 打包

打包的依赖

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

repackage 功能的 作用,就是在打包的时候,多做一点额外的事情:

  1. 首先 mvn package 命令 对项目进行打包,打成一个 jar,这个 jar 就是一个普通的 jar,可以被其他项目依赖,但是不可以被执行
  2. repackage 命令,对第一步 打包成的 jar 进行再次打包,将之打成一个 可执行 jar ,通过将第一步打成的 jar 重命名为 *.original 文件

举个例子:

对任意一个 Spring Boot 项目进行打包,可以执行 mvn package 命令,也可以直接在 IDEA 中点击 package ,如下 :

img

打包成功之后, target 中的文件如下:

img

这里有两个文件,第一个 restful-0.0.1-SNAPSHOT.jar 表示打包成的可执行 jar ,第二个 restful-0.0.1-SNAPSHOT.jar.original 则是在打包过程中 ,被重命名的 jar,这是一个不可执行 jar,但是可以被其他项目依赖的 jar。通过对这两个文件的解压,我们可以看出这两者之间的差异。

可执行 jar 解压之后,目录如下:

img

可以看到,可执行 jar 中,我们自己的代码是存在 于 BOOT-INF/classes/ 目录下,另外,还有一个 META-INF 的目录,该目录下有一个 MANIFEST.MF 文件,打开该文件,内容如下:

Manifest-Version: 1.0
Implementation-Title: restful
Implementation-Version: 0.0.1-SNAPSHOT
Start-Class: org.javaboy.restful.RestfulApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Build-Jdk-Spec: 1.8
Spring-Boot-Version: 2.1.6.RELEASE
Created-By: Maven Archiver 3.4.0
Main-Class: org.springframework.boot.loader.JarLauncher

可以看到,这里定义了一个 Start-Class,这就是可执行 jar 的入口类,Spring-Boot-Classes 表示我们自己代码编译后的位置,Spring-Boot-Lib 则表示项目依赖的 jar 的位置。

换句话说,如果自己要打一个可执行 jar 包的话,除了添加相关依赖之外,还需要配置 META-INF/MANIFEST.MF 文件。

这是可执行 jar 的结构,那么不可执行 jar 的结构呢?

我们首先将默认的后缀 .original 除去,然后给文件重命名,重命名完成,进行解压:

img

解压后可以看到,不可执行 jar 根目录就相当于我们的 classpath,解压之后,直接就能看到我们的代码,它也有 META-INF/MANIFEST.MF 文件,但是文件中没有定义启动类等。

Manifest-Version: 1.0
Implementation-Title: restful
Implementation-Version: 0.0.1-SNAPSHOT
Build-Jdk-Spec: 1.8
Created-By: Maven Archiver 3.4.0

注意

这个不可以执行 jar 也没有将项目的依赖打包进来。

从这里我们就可以看出,两个 jar ,虽然都是 jar 包,但是内部结构是完全不同的,因此一个可以直接执行,另一个则可以被其他项目依赖。

自定义jar包后缀

一般来说,Spring Boot 直接打包成可执行 jar 就可以了,不建议将 Spring Boot 作为普通的 jar 被其他的项目所依赖。如果有这种需求,建议将被依赖的部分,单独抽出来做一个普通的 Maven 项目,然后在 Spring Boot 中引用这个 Maven 项目。

如果非要将 Spring Boot 打包成一个普通 jar 被其他项目依赖,技术上来说,也是可以的,给 spring-boot-maven-plugin 插件添加如下配置:

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <classifier>exec</classifier>
            </configuration>
        </plugin>
    </plugins>
</build>

配置的 classifier 表示可执行 jar 的名字,配置了这个之后,在插件执行 repackage 命令时,就不会给 mvn package 所打成的 jar 重命名了,所以,打包后的 jar 如下:

img

第一个 jar 表示可以被其他项目依赖的 jar ,第二个 jar 则表示一个可执行 jar。

补充

可执行 jar 的文件大小都很大,因为包含了打包项目所依赖的jar包,如果不需要打包的时候把这些包也加进来,可进行如下配置

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <mainClass>org.javaboy.client.ClientApplication</mainClass>
                <layout>ZIP</layout>
                <classifier>exec</classifier>
                <includes>
                    <include>
                        <groupId>org.javaboy</groupId>
                        <artifactId>client</artifactId>
                    </include>
                </includes>
            </configuration>
        </plugin>
    </plugins>
</build>

这样打包后的jar体积就很小了,如果要执行需要加上原先lib所在的目录

java -Dloader.path=./xx/BOOT-INF/lib/ -jar xxx-exec.jar

war包

在创建工程的时候选择war,在依赖中会多一个tomcat的依赖

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

并多了一个初始化的类


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!