Java 项目中使用 Resilience4j 框架实现客户端 API 调用的限速/节流机制


在本系列的上一篇文章中,我们了解了 Resilience4j 以及如何使用其 Retry 模块。现在让我们了解 RateLimiter – 它是什么,何时以及如何使用它,以及在实施速率限制(或者也称为“节流”)时要注意什么。

代码示例

本文附有GitHub 上的工作代码示例。

什么是 Resilience4j?

请参阅上一篇文章中的描述,快速了解 Resilience4j 的一般工作原理

什么是限速?

我们可以从两个角度来看待速率限制——作为服务提供者和作为服务消费者。

服务端限速

作为服务提供商,我们实施速率限制以保护我们的资源免受过载和拒绝服务 (DoS) 攻击

为了满足我们与所有消费者的服务水平协议 (SLA),我们希望确保一个导致流量激增的消费者不会影响我们对他人的服务质量。

我们通过设置在给定时间单位内允许消费者发出多少请求的限制来做到这一点。我们通过适当的响应拒绝任何超出限制的请求,例如 HTTP 状态 429(请求过多)。这称为服务器端速率限制。

速率限制以每秒请求数 (rps)、每分钟请求数 (rpm) 或类似形式指定。某些服务在不同的持续时间(例如 50 rpm 且不超过 2500 rph)和一天中的不同时间(例如,白天 100 rps 和晚上 150 rps)有多个速率限制。该限制可能适用于单个用户(由用户 ID、IP 地址、API 访问密钥等标识)或多租户应用程序中的租户。

客户端限速

作为服务的消费者,我们希望确保我们不会使服务提供者过载。此外,我们不想招致意外的成本——无论是金钱上的还是服务质量方面的。

如果我们消费的服务是有弹性的,就会发生这种情况。服务提供商可能不会限制我们的请求,而是会因额外负载而向我们收取额外费用。有些甚至在短时间内禁止行为不端的客户。消费者为防止此类问题而实施的速率限制称为客户端速率限制。

何时使用 RateLimiter?

resilience4j-ratelimiter 用于客户端速率限制。

服务器端速率限制需要诸如缓存和多个服务器实例之间的协调之类的东西,这是 resilience4j 不支持的。对于服务器端的速率限制,有 API 网关和 API 过滤器,例如 Kong API GatewayRepose API Filter。Resilience4j 的 RateLimiter 模块并不打算取代它们。

Resilience4j RateLimiter 概念

想要调用远程服务的线程首先向 RateLimiter 请求许可。如果 RateLimiter 允许,则线程继续。 否则,RateLimiter 会停放线程或将其置于等待状态。

RateLimiter 定期创建新权限。当权限可用时,线程会收到通知,然后可以继续。

一段时间内允许的调用次数称为 limitForPeriod。RateLimiter 刷新权限的频率由 limitRefreshPeriod 指定。timeoutDuration 指定线程可以等待多长时间获取权限。如果在等待时间结束时没有可用的权限,RateLimiter 将抛出 RequestNotPermitted 运行时异常。

使用Resilience4j RateLimiter 模块

RateLimiterRegistryRateLimiterConfigRateLimiterresilience4j-ratelimiter 的主要抽象。

RateLimiterRegistry 是一个用于创建和管理 RateLimiter 对象的工厂。

RateLimiterConfig 封装了 limitForPeriodlimitRefreshPeriodtimeoutDuration 配置。每个 RateLimiter 对象都与一个 RateLimiterConfig 相关联。

RateLimiter 提供辅助方法来为包含远程调用的函数式接口或 lambda 表达式创建装饰器。

让我们看看如何使用 RateLimiter 模块中可用的各种功能。假设我们正在为一家航空公司建立一个网站,以允许其客户搜索和预订航班。我们的服务与 FlightSearchService 类封装的远程服务对话。

基本示例

第一步是创建一个 RateLimiterConfig

RateLimiterConfig config = RateLimiterConfig.ofDefaults();

这将创建一个 RateLimiterConfig,其默认值为 limitForPeriod (50)、limitRefreshPeriod(500ns) 和 timeoutDuration (5s)。

假设我们与航空公司服务的合同规定我们可以以 1 rps 调用他们的搜索 API。然后我们将像这样创建 RateLimiterConfig

RateLimiterConfig config = RateLimiterConfig.custom()
  .limitForPeriod(1)
  .limitRefreshPeriod(Duration.ofSeconds(1))
  .timeoutDuration(Duration.ofSeconds(1))
  .build();

如果线程无法在指定的 1 秒 timeoutDuration 内获取权限,则会出错。

然后我们创建一个 RateLimiter 并装饰 searchFlights() 调用:

RateLimiterRegistry registry = RateLimiterRegistry.of(config);
RateLimiter limiter = registry.rateLimiter("flightSearchService");
// FlightSearchService and SearchRequest creation omitted
Supplier<List<Flight>> flightsSupplier =
  RateLimiter.decorateSupplier(limiter,
    () -> service.searchFlights(request));

最后,我们多次使用装饰过的 Supplier<List<Flight>>

for (int i=0; i<3; i++) {
  System.out.println(flightsSupplier.get());
}

示例输出中的时间戳显示每秒发出一个请求:

Searching for flights; current time = 15:29:40 786
...
[Flight{flightNumber='XY 765', ... }, ... ]
Searching for flights; current time = 15:29:41 791
...
[Flight{flightNumber='XY 765', ... }, ... ]

如果超出限制,我们会收到 RequestNotPermitted 异常:

Exception in thread "main" io.github.resilience4j.ratelimiter.RequestNotPermitted: RateLimiter 'flightSearchService' does not permit further calls at io.github.resilience4j.ratelimiter.RequestNotPermitted.createRequestNotPermitted(RequestNotPermitted.java:43)

 at io.github.resilience4j.ratelimiter.RateLimiter.waitForPermission(RateLimiter.java:580)

... other lines omitted ...

装饰方法抛出已检异常

假设我们正在调用
FlightSearchService.searchFlightsThrowingException() ,它可以抛出一个已检 Exception。那么我们就不能使用
RateLimiter.decorateSupplier()。我们将使用
RateLimiter.decorateCheckedSupplier() 代替:

CheckedFunction0<List<Flight>> flights =
  RateLimiter.decorateCheckedSupplier(limiter,
    () -> service.searchFlightsThrowingException(request));

try {
  System.out.println(flights.apply());
} catch (...) {
  // exception handling
}

RateLimiter.decorateCheckedSupplier() 返回一个 CheckedFunction0,它表示一个没有参数的函数。请注意对 CheckedFunction0 对象的 apply() 调用以调用远程操作。

如果我们不想使用 SuppliersRateLimiter 提供了更多的辅助装饰器方法,如 decorateFunction()decorateCheckedFunction()decorateRunnable()decorateCallable() 等,以与其他语言结构一起使用。decorateChecked* 方法用于装饰抛出已检查异常的方法。

应用多个速率限制

假设航空公司的航班搜索有多个速率限制:2 rps 和 40 rpm。 我们可以通过创建多个 RateLimiters 在客户端应用多个限制:

RateLimiterConfig rpsConfig = RateLimiterConfig.custom().
  limitForPeriod(2).
  limitRefreshPeriod(Duration.ofSeconds(1)).
  timeoutDuration(Duration.ofMillis(2000)).build();

RateLimiterConfig rpmConfig = RateLimiterConfig.custom().
  limitForPeriod(40).
  limitRefreshPeriod(Duration.ofMinutes(1)).
  timeoutDuration(Duration.ofMillis(2000)).build();

RateLimiterRegistry registry = RateLimiterRegistry.of(rpsConfig);
RateLimiter rpsLimiter =
  registry.rateLimiter("flightSearchService_rps", rpsConfig);
RateLimiter rpmLimiter =
  registry.rateLimiter("flightSearchService_rpm", rpmConfig);  
然后我们使用两个 RateLimiters 装饰 searchFlights() 方法:

Supplier<List<Flight>> rpsLimitedSupplier =
  RateLimiter.decorateSupplier(rpsLimiter,
    () -> service.searchFlights(request));

Supplier<List<Flight>> flightsSupplier
  = RateLimiter.decorateSupplier(rpmLimiter, rpsLimitedSupplier);

示例输出显示每秒发出 2 个请求,并且限制为 40 个请求:

Searching for flights; current time = 15:13:21 246
...
Searching for flights; current time = 15:13:21 249
...
Searching for flights; current time = 15:13:22 212
...
Searching for flights; current time = 15:13:40 215
...
Exception in thread "main" io.github.resilience4j.ratelimiter.RequestNotPermitted:
RateLimiter 'flightSearchService_rpm' does not permit further calls
at io.github.resilience4j.ratelimiter.RequestNotPermitted.createRequestNotPermitted(RequestNotPermitted.java:43)
at io.github.resilience4j.ratelimiter.RateLimiter.waitForPermission(RateLimiter.java:580)

在运行时更改限制

如果需要,我们可以在运行时更改 limitForPeriodtimeoutDuration 的值:

limiter.changeLimitForPeriod(2);
limiter.changeTimeoutDuration(Duration.ofSeconds(2));

例如,如果我们的速率限制根据一天中的时间而变化,则此功能很有用 – 我们可以有一个计划线程来更改这些值。新值不会影响当前正在等待权限的线程。

RateLimiter和 Retry一起使用

假设我们想在收到 RequestNotPermitted 异常时重试,因为它是一个暂时性错误。我们会像往常一样创建 RateLimiterRetry 对象。然后我们装饰一个 Supplier 的供应商并用 Retry 包装它:

Supplier<List<Flight>> rateLimitedFlightsSupplier =
  RateLimiter.decorateSupplier(rateLimiter,
    () -> service.searchFlights(request));

Supplier<List<Flight>> retryingFlightsSupplier =
  Retry.decorateSupplier(retry, rateLimitedFlightsSupplier);

示例输出显示为 RequestNotPermitted 异常重试请求:

Searching for flights; current time = 15:29:39 847
Flight search successful
[Flight{flightNumber='XY 765', ... }, ... ]
Searching for flights; current time = 17:10:09 218
...
[Flight{flightNumber='XY 765', flightDate='07/31/2020', from='NYC', to='LAX'}, ...]
2020-07-27T17:10:09.484: Retry 'rateLimitedFlightSearch', waiting PT1S until attempt '1'. Last attempt failed with exception 'io.github.resilience4j.ratelimiter.RequestNotPermitted: RateLimiter 'flightSearchService' does not permit further calls'.
Searching for flights; current time = 17:10:10 492
...
2020-07-27T17:10:10.494: Retry 'rateLimitedFlightSearch' recorded a successful retry attempt...
[Flight{flightNumber='XY 765', flightDate='07/31/2020', from='NYC', to='LAX'}, ...]

我们创建装饰器的顺序很重要。如果我们将 RetryRateLimiter 包装在一起,它将不起作用。

RateLimiter 事件

RateLimiter 有一个 EventPublisher,它在调用远程操作时生成 RateLimiterOnSuccessEventRateLimiterOnFailureEvent 类型的事件,以指示获取权限是否成功。我们可以监听这些事件并记录它们,例如:

RateLimiter limiter = registry.rateLimiter("flightSearchService");
limiter.getEventPublisher().onSuccess(e -> System.out.println(e.toString()));
limiter.getEventPublisher().onFailure(e -> System.out.println(e.toString()));

日志输出示例如下:

RateLimiterEvent{type=SUCCESSFUL_ACQUIRE, rateLimiterName='flightSearchService', creationTime=2020-07-21T19:14:33.127+05:30}
... other lines omitted ...
RateLimiterEvent{type=FAILED_ACQUIRE, rateLimiterName='flightSearchService', creationTime=2020-07-21T19:14:33.186+05:30}

RateLimiter 指标

假设在实施客户端节流后,我们发现 API 的响应时间增加了。这是可能的 – 正如我们所见,如果在线程调用远程操作时权限不可用,RateLimiter 会将线程置于等待状态。

如果我们的请求处理线程经常等待获得许可,则可能意味着我们的 limitForPeriod 太低。也许我们需要与我们的服务提供商合作并首先获得额外的配额。

监控 RateLimiter 指标可帮助我们识别此类容量问题,并确保我们在 RateLimiterConfig 上设置的值运行良好。

RateLimiter 跟踪两个指标:可用权限的数量(
resilience4j.ratelimiter.available.permissions)和等待权限的线程数量(
resilience4j.ratelimiter.waiting.threads)。

首先,我们像往常一样创建 RateLimiterConfigRateLimiterRegistryRateLimiter。然后,我们创建一个 MeterRegistry 并将 RateLimiterRegistry 绑定到它:

MeterRegistry meterRegistry = new SimpleMeterRegistry();
TaggedRateLimiterMetrics.ofRateLimiterRegistry(registry)
  .bindTo(meterRegistry);

运行几次限速操作后,我们显示捕获的指标:

Consumer<Meter> meterConsumer = meter -> {
  String desc = meter.getId().getDescription();
  String metricName = meter.getId().getName();
  Double metricValue = StreamSupport.stream(meter.measure().spliterator(), false)
    .filter(m -> m.getStatistic().name().equals("VALUE"))
    .findFirst()
    .map(m -> m.getValue())
    .orElse(0.0);
  System.out.println(desc + " - " + metricName + ": " + metricValue);};meterRegistry.forEachMeter(meterConsumer);

这是一些示例输出:

The number of available permissions - resilience4j.ratelimiter.available.permissions: -6.0
The number of waiting threads - resilience4j.ratelimiter.waiting_threads: 7.0

resilience4j.ratelimiter.available.permissions 的负值显示为请求线程保留的权限数。在实际应用中,我们会定期将数据导出到监控系统,并在仪表板上进行分析。

实施客户端速率限制时的陷阱和良好实践

使速率限制器成为单例

对给定远程服务的所有调用都应通过相同的 RateLimiter 实例。对于给定的远程服务,RateLimiter 必须是单例。

如果我们不强制执行此操作,我们代码库的某些区域可能会绕过 RateLimiter 直接调用远程服务。为了防止这种情况,对远程服务的实际调用应该在核心、内部层和其他区域应该使用内部层暴露的限速装饰器。

我们如何确保未来的新开发人员理解这一意图?查看 Tom 的文章,其中揭示了一种解决此类问题的方法,即通过组织包结构来明确此类意图。此外,它还展示了如何通过在 ArchUnit 测试中编码意图来强制执行此操作。

为多个服务器实例配置速率限制器

为配置找出正确的值可能很棘手。如果我们在集群中运行多个服务实例,limitForPeriod 的值必须考虑到这一点

例如,如果上游服务的速率限制为 100 rps,而我们的服务有 4 个实例,那么我们将配置 25 rps 作为每个实例的限制。

然而,这假设我们每个实例上的负载大致相同。 如果情况并非如此,或者如果我们的服务本身具有弹性并且实例数量可能会有所不同,那么 Resilience4j 的 RateLimiter 可能不适合

在这种情况下,我们需要一个速率限制器,将其数据保存在分布式缓存中,而不是像 Resilience4j RateLimiter 那样保存在内存中。但这会影响我们服务的响应时间。另一种选择是实现某种自适应速率限制。尽管 Resilience4j 可能会支持它,但尚不清楚何时可用。

选择正确的超时时间

对于 timeoutDuration 配置值,我们应该牢记 API 的预期响应时间

如果我们将 timeoutDuration 设置得太高,响应时间和吞吐量就会受到影响。如果它太低,我们的错误率可能会增加。

由于此处可能涉及一些反复试验,因此一个好的做法是将我们在 RateLimiterConfig 中使用的值(如 timeoutDurationlimitForPeriodlimitRefreshPeriod)作为我们服务之外的配置进行维护。然后我们可以在不更改代码的情况下更改它们。

调优客户端和服务器端速率限制器

实现客户端速率限制并不能保证我们永远不会受到上游服务的速率限制

假设我们有来自上游服务的 2 rps 的限制,并且我们将 limitForPeriod 配置为 2,将 limitRefreshPeriod 配置为 1s。如果我们在第二秒的最后几毫秒发出两个请求,在此之前没有其他调用,RateLimiter 将允许它们。如果我们在下一秒的前几毫秒内再进行两次调用,RateLimiter 也会允许它们,因为有两个新权限可用。但是上游服务可能会拒绝这两个请求,因为服务器通常会实现基于滑动窗口的速率限制。

为了保证我们永远不会从上游服务中获得超过速率,我们需要将客户端中的固定窗口配置为短于服务中的滑动窗口。因此,如果我们在前面的示例中将 limitForPeriod 配置为 1 并将 limitRefreshPeriod 配置为 500ms,我们就不会出现超出速率限制的错误。但是,第一个请求之后的所有三个请求都会等待,从而增加响应时间并降低吞吐量。

结论

在本文中,我们学习了如何使用 Resilience4j 的 RateLimiter 模块来实现客户端速率限制。 我们通过实际示例研究了配置它的不同方法。我们学习了一些在实施速率限制时要记住的良好做法和注意事项。

您可以使用 GitHub 上的代码演示一个完整的应用程序来说明这些想法。


本文译自: Implementing Rate Limiting with Resilience4j – Reflectoring

在 Spring Boot 中使用搜索引擎 Elasticsearch


Elasticsearch 建立在 Apache Lucene 之上,于 2010 年由 Elasticsearch NV(现为 Elastic)首次发布。据 Elastic 网站称,它是一个分布式开源搜索和分析引擎,适用于所有类型的数据,包括文本、数值 、地理空间、结构化和非结构化。Elasticsearch 操作通过 REST API 实现。主要功能是:

  • 将文档存储在索引中,
  • 使用强大的查询搜索索引以获取这些文档,以及
  • 对数据运行分析函数。

Spring Data Elasticsearch 提供了一个简单的接口来在 Elasticsearch 上执行这些操作,作为直接使用 REST API 的替代方法
在这里,我们将使用 Spring Data Elasticsearch 来演示 Elasticsearch 的索引和搜索功能,并在最后构建一个简单的搜索应用程序,用于在产品库存中搜索产品。

代码示例

本文附有 GitHub 上的工作代码示例。

Elasticsearch 概念

Elasticsearch 概念
了解 Elasticsearch 概念的最简单方法是用数据库进行类比,如下表所示:

Elasticsearch -> 数据库
索引 ->
文档 ->
文档 ->

我们要搜索或分析的任何数据都作为文档存储在索引中。在 Spring Data 中,我们以 POJO 的形式表示一个文档,并用注解对其进行修饰以定义到 Elasticsearch 文档的映射。

与数据库不同,存储在 Elasticsearch 中的文本首先由各种分析器处理。默认分析器通过常用单词分隔符(如空格和标点符号)拆分文本,并删除常用英语单词。

如果我们存储文本“The sky is blue”,分析器会将其存储为包含“术语”“sky”和“blue”的文档。我们将能够使用“blue sky”、“sky”或“blue”形式的文本搜索此文档,并将匹配程度作为分数。

除了文本之外,Elasticsearch 还可以存储其他类型的数据,称为 Field Type(字段类型),如文档中 mapping-types (映射类型)部分所述。

启动 Elasticsearch 实例

在进一步讨论之前,让我们启动一个 Elasticsearch 实例,我们将使用它来运行我们的示例。有多种运行 Elasticsearch 实例的方法:

  • 使用托管服务
  • 使用来自 AWS 或 Azure 等云提供商的托管服务
  • 通过在虚拟机集群中自己安装 Elasticsearch
  • 运行 Docker 镜像
    我们将使用来自 Dockerhub 的 Docker 镜像,这对于我们的演示应用程序来说已经足够了。让我们通过运行 Docker run 命令来启动 Elasticsearch 实例:
docker run -p 9200:9200 \
  -e "discovery.type=single-node" \
  docker.elastic.co/elasticsearch/elasticsearch:7.10.0

执行此命令将启动一个 Elasticsearch 实例,侦听端口 9200。我们可以通过点击 URL http://localhost:9200 来验证实例状态,并在浏览器中检查结果输出:

{
  "name" : "8c06d897d156",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "Jkx..VyQ",
  "version" : {
  "number" : "7.10.0",
  ...
  },
  "tagline" : "You Know, for Search"
}

如果我们的 Elasticsearch 实例启动成功,应该看到上面的输出。

使用 REST API 进行索引和搜索

Elasticsearch 操作通过 REST API 访问。 有两种方法可以将文档添加到索引中:

  • 一次添加一个文档,或者
  • 批量添加文档。

添加单个文档的 API 接受一个文档作为参数。

对 Elasticsearch 实例的简单 PUT 请求用于存储文档如下所示:

PUT /messages/_doc/1
{
  "message": "The Sky is blue today"
}

这会将消息 – “The Sky is blue today”存储为“messages”的索引中的文档。

我们可以使用发送到搜索 REST API 的搜索查询来获取此文档:

GET /messages/search
{
  "query":
  {
  "match": {"message": "blue sky"}
  }
}

这里我们发送一个 match 类型的查询来获取匹配字符串“blue sky”的文档。我们可以通过多种方式指定用于搜索文档的查询。Elasticsearch 提供了一个基于 JSON 的 查询 DSL(Domain Specific Language – 领域特定语言)来定义查询。

对于批量添加,我们需要提供一个包含类似以下代码段的条目的 JSON 文档:

POST /_bulk
{"index":{"_index":"productindex"}}{"_class":"..Product","name":"Corgi Toys .. Car",..."manufacturer":"Hornby"}{"index":{"_index":"productindex"}}{"_class":"..Product","name":"CLASSIC TOY .. BATTERY"...,"manufacturer":"ccf"}

使用 Spring Data 进行 Elasticsearch 操作

我们有两种使用 Spring Data 访问 Elasticsearch 的方法,如下所示:

  • Repositories:我们在接口中定义方法,Elasticsearch 查询是在运行时根据方法名称生成的。
  • ElasticsearchRestTemplate:我们使用方法链和原生查询创建查询,以便在相对复杂的场景中更好地控制创建 Elasticsearch 查询。

我们将在以下各节中更详细地研究这两种方式。

创建应用程序并添加依赖项

让我们首先通过包含 web、thymeleaf 和 lombok 的依赖项,使用 Spring Initializr 创建我们的应用程序。添加 thymeleaf 依赖项以便增加用户界面。

在 Maven pom.xml 中添加 spring-data-elasticsearch 依赖项:

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-elasticsearch</artifactId>
</dependency>

连接到 Elasticsearch 实例

Spring Data Elasticsearch 使用 Java High Level REST Client (JHLC) 连接到 Elasticsearch 服务器。JHLC 是 Elasticsearch 的默认客户端。我们将创建一个 Spring Bean 配置来进行设置:

@Configuration
@EnableElasticsearch
Repositories(basePackages
        = "io.pratik.elasticsearch.repositories")@ComponentScan(basePackages = { "io.pratik.elasticsearch" })
public class ElasticsearchClientConfig extends
         AbstractElasticsearchConfiguration {
  @Override
  @Bean
  public RestHighLevelClient elasticsearchClient() {

  final ClientConfiguration clientConfiguration =
    ClientConfiguration
      .builder()
      .connectedTo("localhost:9200")
      .build();

  return RestClients.create(clientConfiguration).rest();
  }
}

在这里,我们连接到我们之前启动的 Elasticsearch 实例。我们可以通过添加更多属性(例如启用 ssl、设置超时等)来进一步自定义连接。

为了调试和诊断,我们将在 logback-spring.xml 的日志配置中打开传输级别的请求/响应日志:

public class Product {
  @Id
  private String id;

  @Field(type = FieldType.Text, name = "name")
  private String name;

  @Field(type = FieldType.Double, name = "price")
  private Double price;

  @Field(type = FieldType.Integer, name = "quantity")
  private Integer quantity;

  @Field(type = FieldType.Keyword, name = "category")
  private String category;

  @Field(type = FieldType.Text, name = "desc")
  private String description;

  @Field(type = FieldType.Keyword, name = "manufacturer")
  private String manufacturer;

  ...
}

表达文档

在我们的示例中,我们将按名称、品牌、价格或描述搜索产品。因此,为了将产品作为文档存储在 Elasticsearch 中,我们将产品表示为 POJO,并加上 Field 注解以配置 Elasticsearch 的映射,如下所示:

public class Product {
  @Id
  private String id;

  @Field(type = FieldType.Text, name = "name")
  private String name;

  @Field(type = FieldType.Double, name = "price")
  private Double price;

  @Field(type = FieldType.Integer, name = "quantity")
  private Integer quantity;

  @Field(type = FieldType.Keyword, name = "category")
  private String category;

  @Field(type = FieldType.Text, name = "desc")
  private String description;

  @Field(type = FieldType.Keyword, name = "manufacturer")
  private String manufacturer;

  ...
}

@Document 注解指定索引名称。

@Id 注解使注解字段成为文档的 _id,作为此索引中的唯一标识符。id 字段有 512 个字符的限制。

@Field 注解配置字段的类型。我们还可以将名称设置为不同的字段名称。

在 Elasticsearch 中基于这些注解创建了名为 productindex 的索引。

使用 Spring Data Repository 进行索引和搜索

存储库提供了使用 finder 方法访问 Spring Data 中数据的最方便的方法。Elasticsearch 查询是根据方法名称创建的。但是,我们必须小心避免产生低效的查询并给集群带来高负载。

让我们通过扩展 ElasticsearchRepository 接口来创建一个 Spring Data 存储库接口:

public interface ProductRepository
    extends ElasticsearchRepository<Product, String> {

}

此处 ProductRepository 类继承了 ElasticsearchRepository 接口中包含的 save()saveAll()find()findAll() 等方法。

索引

我们现在将通过调用 save() 方法存储一个产品,调用 saveAll() 方法来批量索引,从而在索引中存储一些产品。在此之前,我们将存储库接口放在一个服务类中:

@Service
public class ProductSearchServiceWithRepo {

  private ProductRepository productRepository;

  public void createProductIndexBulk(final List<Product> products) {
    productRepository.saveAll(products);
  }

  public void createProductIndex(final Product product) {
    productRepository.save(product);
  }
}

当我们从 JUnit 调用这些方法时,我们可以在跟踪日志中看到 REST API 调用索引和批量索引。

搜索

为了满足我们的搜索要求,我们将向存储库接口添加 finder 方法:

public interface ProductRepository
    extends ElasticsearchRepository<Product, String> {
  List<Product> findByName(String name);

  List<Product> findByNameContaining(String name);
  List<Product> findByManufacturerAndCategory
       (String manufacturer, String category);
}

在使用 JUnit 运行 findByName() 方法时,我们可以看到在发送到服务器之前在跟踪日志中生成的 Elasticsearch 查询:

TRACE Sending request POST /productindex/_search? ..:
Request body: {.."query":{"bool":{"must":[{"query_string":{"query":"apple","fields":["name^1.0"],..}

类似地,通过运行
findByManufacturerAndCategory() 方法,我们可以看到使用两个 query_string 参数对应两个字段——“manufacturer”和“category”生成的查询:

TRACE .. Sending request POST /productindex/_search..:
Request body: {.."query":{"bool":{"must":[{"query_string":{"query":"samsung","fields":["manufacturer^1.0"],..}},{"query_string":{"query":"laptop","fields":["category^1.0"],..}}],..}},"version":true}

有多种方法命名模式可以生成各种 Elasticsearch 查询。

使用 ElasticsearchRestTemplate进行索引和搜索

当我们需要更多地控制我们设计查询的方式,或者团队已经掌握了 Elasticsearch 语法时,Spring Data 存储库可能就不再适合。

在这种情况下,我们使用 ElasticsearchRestTemplate。它是 Elasticsearch 基于 HTTP 的新客户端,取代以前使用节点到节点二进制协议的 TransportClient。

ElasticsearchRestTemplate 实现了接口 ElasticsearchOperations,该接口负责底层搜索和集群操的繁杂工作。

索引

该接口具有用于添加单个文档的方法 index() 和用于向索引添加多个文档的 bulkIndex() 方法。此处的代码片段显示了如何使用 bulkIndex() 将多个产品添加到索引“productindex”:

@Service
@Slf4j
public class ProductSearchService {

  private static final String PRODUCT_INDEX = "productindex";
  private ElasticsearchOperations elasticsearchOperations;

  public List<String> createProductIndexBulk
            (final List<Product> products) {

      List<IndexQuery> queries = products.stream()
      .map(product->
        new IndexQueryBuilder()
        .withId(product.getId().toString())
        .withObject(product).build())
      .collect(Collectors.toList());;

      return elasticsearchOperations
      .bulkIndex(queries,IndexCoordinates.of(PRODUCT_INDEX));
  }
  ...
}

要存储的文档包含在 IndexQuery 对象中。bulkIndex() 方法将 IndexQuery 对象列表和包含在 IndexCoordinates 中的 Index 名称作为输入。当我们执行此方法时,我们会获得批量请求的 REST API 跟踪:

Sending request POST /_bulk?timeout=1m with parameters:
Request body: {"index":{"_index":"productindex","_id":"383..35"}}{"_class":"..Product","id":"383..35","name":"New Apple..phone",..manufacturer":"apple"}
..
{"_class":"..Product","id":"d7a..34",.."manufacturer":"samsung"}

接下来,我们使用 index() 方法添加单个文档:

@Service
@Slf4j
public class ProductSearchService {

  private static final String PRODUCT_INDEX = "productindex";

  private ElasticsearchOperations elasticsearchOperations;

  public String createProductIndex(Product product) {

    IndexQuery indexQuery = new IndexQueryBuilder()
         .withId(product.getId().toString())
         .withObject(product).build();

    String documentId = elasticsearchOperations
     .index(indexQuery, IndexCoordinates.of(PRODUCT_INDEX));

    return documentId;
  }
}

跟踪相应地显示了用于添加单个文档的 REST API PUT 请求。

Sending request PUT /productindex/_doc/59d..987..:
Request body: {"_class":"..Product","id":"59d..87",..,"manufacturer":"dell"}

搜索

ElasticsearchRestTemplate 还具有 search() 方法,用于在索引中搜索文档。此搜索操作类似于 Elasticsearch 查询,是通过构造 Query 对象并将其传递给搜索方法来构建的。

Query 对象具有三种变体 – NativeQueryyStringQueryCriteriaQuery,具体取决于我们如何构造查询。让我们构建一些用于搜索产品的查询。

NativeQuery

NativeQuery 为使用表示 Elasticsearch 构造(如聚合、过滤和排序)的对象构建查询提供了最大的灵活性。这是用于搜索与特定制造商匹配的产品的 NativeQuery

@Service
@Slf4j
public class ProductSearchService {

  private static final String PRODUCT_INDEX = "productindex";
  private ElasticsearchOperations elasticsearchOperations;

  public void findProductsByBrand(final String brandName) {

    QueryBuilder queryBuilder =
      QueryBuilders
      .matchQuery("manufacturer", brandName);

    Query searchQuery = new NativeSearchQueryBuilder()
      .withQuery(queryBuilder)
      .build();

    SearchHits<Product> productHits =
      elasticsearchOperations
      .search(searchQuery,
          Product.class,
          IndexCoordinates.of(PRODUCT_INDEX));
  }
}

在这里,我们使用 NativeSearchQueryBuilder 构建查询,该查询使用 MatchQueryBuilder 指定包含字段“制造商”的匹配查询。

StringQuery

StringQuery 通过允许将原生 Elasticsearch 查询用作 JSON 字符串来提供完全控制,如下所示:

@Service
@Slf4j
public class ProductSearchService {

  private static final String PRODUCT_INDEX = "productindex";
  private ElasticsearchOperations elasticsearchOperations;

  public void findByProductName(final String productName) {
    Query searchQuery = new StringQuery(
      "{\"match\":{\"name\":{\"query\":\""+ productName + "\"}}}\"");

    SearchHits<Product> products = elasticsearchOperations.search(
      searchQuery,
      Product.class,
      IndexCoordinates.of(PRODUCT_INDEX_NAME));
  ...     
   }
}

在此代码片段中,我们指定了一个简单的 match 查询,用于获取具有作为方法参数发送的特定名称的产品。

CriteriaQuery

使用 CriteriaQuery,我们可以在不了解 Elasticsearch 任何术语的情况下构建查询。查询是使用带有 Criteria 对象的方法链构建的。每个对象指定一些用于搜索文档的标准:

@Service
@Slf4j
public class ProductSearchService {

  private static final String PRODUCT_INDEX = "productindex";

  private ElasticsearchOperations elasticsearchOperations;

  public void findByProductPrice(final String productPrice) {
    Criteria criteria = new Criteria("price")
                  .greaterThan(10.0)
                  .lessThan(100.0);

    Query searchQuery = new CriteriaQuery(criteria);

    SearchHits<Product> products = elasticsearchOperations
       .search(searchQuery,
           Product.class,
           IndexCoordinates.of(PRODUCT_INDEX_NAME));
  }
}

在此代码片段中,我们使用 CriteriaQuery 形成查询以获取价格大于 10.0 且小于 100.0 的产品。

构建搜索应用程序

我们现在将向我们的应用程序添加一个用户界面,以查看产品搜索的实际效果。用户界面将有一个搜索输入框,用于按名称或描述搜索产品。输入框将具有自动完成功能,以显示基于可用产品的建议列表,如下所示:

我们将为用户的搜索输入创建自动完成建议。然后根据与用户输入的搜索文本密切匹配的名称或描述搜索产品。我们将构建两个搜索服务来实现这个用例:

  • 获取自动完成功能的搜索建议
  • 根据用户的搜索查询处理搜索产品的搜索
    服务类 ProductSearchService 将包含搜索和获取建议的方法。

GitHub 存储库中提供了带有用户界面的成熟应用程序。

建立产品搜索索引

productindex 与我们之前用于运行 JUnit 测试的索引相同。我们将首先使用 Elasticsearch REST API 删除 productindex,以便在应用程序启动期间使用从我们的 50 个时尚系列产品的示例数据集中加载的产品创建新的 productindex

curl -X DELETE http://localhost:9200/productindex

如果删除操作成功,我们将收到消息 "acknowledged": true

现在,让我们为库存中的产品创建一个索引。我们将使用包含 50 种产品的示例数据集来构建我们的索引。这些产品在 CSV 文件中被排列为单独的行。

每行都有三个属性 – id、name 和 description。我们希望在应用程序启动期间创建索引。请注意,在实际生产环境中,索引创建应该是一个单独的过程。我们将读取 CSV 的每一行并将其添加到产品索引中:

@SpringBootApplication
@Slf4j
public class ProductsearchappApplication {
  ...
  @PostConstruct
  public void buildIndex() {
    esOps.indexOps(Product.class).refresh();
    productRepo.saveAll(prepareDataset());
  }

  private Collection<Product> prepareDataset() {
    Resource resource = new ClassPathResource("fashion-products.csv");
    ...
    return productList;
  }
}

在这个片段中,我们通过从数据集中读取行并将这些行传递给存储库的 saveAll() 方法以将产品添加到索引中来进行一些预处理。在运行应用程序时,我们可以在应用程序启动中看到以下跟踪日志。

...Sending request POST /_bulk?timeout=1m with parameters:
Request body: {"index":{"_index":"productindex"}}{"_class":"io.pratik.elasticsearch.productsearchapp.Product","name":"Hornby 2014 Catalogue","description":"Product Desc..talogue","manufacturer":"Hornby"}{"index":{"_index":"productindex"}}{"_class":"io.pratik.elasticsearch.productsearchapp.Product","name":"FunkyBuys..","description":"Size Name:Lar..& Smoke","manufacturer":"FunkyBuys"}{"index":{"_index":"productindex"}}.
...

使用多字段和模糊搜索搜索产品

下面是我们在方法 processSearch() 中提交搜索请求时如何处理搜索请求:

@Service
@Slf4j
public class ProductSearchService {

  private static final String PRODUCT_INDEX = "productindex";

  private ElasticsearchOperations elasticsearchOperations;

  public List<Product> processSearch(final String query) {
  log.info("Search with query {}", query);

  // 1. Create query on multiple fields enabling fuzzy search
  QueryBuilder queryBuilder =
    QueryBuilders
    .multiMatchQuery(query, "name", "description")
    .fuzziness(Fuzziness.AUTO);

  Query searchQuery = new NativeSearchQueryBuilder()
            .withFilter(queryBuilder)
            .build();

  // 2. Execute search
  SearchHits<Product> productHits =
    elasticsearchOperations
    .search(searchQuery, Product.class,
    IndexCoordinates.of(PRODUCT_INDEX));

  // 3. Map searchHits to product list
  List<Product> productMatches = new ArrayList<Product>();
  productHits.forEach(searchHit->{
    productMatches.add(searchHit.getContent());
  });
  return productMatches;
  }...
}

在这里,我们对多个字段执行搜索 – 名称和描述。 我们还附加了 fuzziness() 来搜索紧密匹配的文本以解释拼写错误。

使用通配符搜索获取建议

接下来,我们为搜索文本框构建自动完成功能。 当我们在搜索文本字段中输入内容时,我们将通过使用搜索框中输入的字符执行通配符搜索来获取建议。

我们在 fetchSuggestions() 方法中构建此函数,如下所示:

@Service
@Slf4j
public class ProductSearchService {

  private static final String PRODUCT_INDEX = "productindex";

  public List<String> fetchSuggestions(String query) {
    QueryBuilder queryBuilder = QueryBuilders
      .wildcardQuery("name", query+"*");

    Query searchQuery = new NativeSearchQueryBuilder()
      .withFilter(queryBuilder)
      .withPageable(PageRequest.of(0, 5))
      .build();

    SearchHits<Product> searchSuggestions =
      elasticsearchOperations.search(searchQuery,
        Product.class,
      IndexCoordinates.of(PRODUCT_INDEX));

    List<String> suggestions = new ArrayList<String>();

    searchSuggestions.getSearchHits().forEach(searchHit->{
      suggestions.add(searchHit.getContent().getName());
    });
    return suggestions;
  }
}

我们以搜索输入文本的形式使用通配符查询,并附加 * 以便如果我们输入“red”,我们将获得以“red”开头的建议。我们使用 withPageable() 方法将建议的数量限制为 5。可以在此处看到正在运行的应用程序的搜索结果的一些屏幕截图:

结论

在本文中,我们介绍了 Elasticsearch 的主要操作——索引文档、批量索引和搜索——它们以 REST API 的形式提供。Query DSL 与不同分析器的结合使搜索变得非常强大。

Spring Data Elasticsearch 通过使用 Spring Data Repositories 或 ElasticsearchRestTemplate 提供了方便的接口来访问应用程序中的这些操作。

我们最终构建了一个应用程序,在其中我们看到了如何在接近现实生活的应用程序中使用 Elasticsearch 的批量索引和搜索功能。


Java Spring Boot 项目中使用结构化日志节省时间

【注】本文译自: Saving Time with Structured Logging – Reflectoring

日志记录是调查事件和了解应用程序中发生的事情的终极资源。每个应用程序都有某种类型的日志。

然而,这些日志通常很混乱,分析它们需要付出很多努力。在本文中,我们将研究如何利用结构化日志来大大增加日志的价值

我们将通过一些非常实用的技巧来提高应用程序日志数据的价值,并使用 Logz.io 作为日志平台来查询日志。

代码示例

本文附有 GitHub 上的工作代码示例。

什么是结构化日志?

“正常”日志是非结构化的。它们通常包含一个消息字符串:

2021-08-08 18:04:14.721 INFO 12402 --- [ main] i.r.s.StructuredLoggingApplication : Started StructuredLoggingApplication in 0.395 seconds (JVM running for 0.552)

此消息包含我们在调查事件或分析问题时希望获得的所有信息:

  • 日志事件的日期
  • 创建日志事件的记录器的名称,以及
  • 日志消息本身。
    所有信息都在该日志消息中,但很难查询这些信息!由于所有信息都在一个字符串中,如果我们想从日志中获取特定信息,就必须解析和搜索这个字符串。

例如,如果我们只想查看特定记录器的日志,则日志服务器必须解析所有日志消息,检查它们是否具有识别记录器的特定模式,然后根据所需的记录器过滤日志消息。

结构化日志包含相同的信息,但采用结构化形式而不是非结构化字符串。通常,结构化日志以 JSON 格式呈现:

{
    "timestamp": "2021-08-08 18:04:14.721",
    "level": "INFO",
    "logger": "io.reflectoring....StructuredLoggingApplication",
    "thread": "main",
    "message": "Started StructuredLoggingApplication ..."
}

这种 JSON 结构允许日志服务器有效地存储,更重要的是检索日志。

例如,现在可以通过 timestamplogger 轻松过滤日志,而且搜索比解析特定模式的字符串更有效。

但是结构化日志的价值并不止于此:我们可以根据需要向结构化日志事件中添加任何自定义字段! 我们可以添加上下文信息来帮助我们识别问题,或者我们可以向日志添加指标。

凭借我们现在触手可及的所有数据,我们可以创建强大的日志查询和仪表板,即使我们刚在半夜醒来调查事件,我们也能找到所需的信息。

现在让我们看几个用例,它们展示了结构化日志记录的强大功能。

为所有日志事件添加代码路径

我们首先要看的是代码路径。每个应用程序通常有几个不同的路径,传入请求可以通过应用程序。考虑这个图:

Java Spring Boot 项目中使用结构化日志节省时间
此示例具有(至少)三种不同的代码路径,传入请求可以采用这些路径:

  • 用户代码路径:用户正在从他们的浏览器使用应用程序。浏览器向 Web 控制器发送请求,控制器调用领域代码。
  • 第三方系统代码路径:应用程序的 HTTP API 也从第三方系统调用。在这个例子中,第三方系统调用与用户浏览器相同的 web 控制器。
  • 计时器代码路径:与许多应用程序一样,此应用程序有一些由计时器触发的计划任务。
    这些代码路径中的每一个都可以具有不同的特征。域服务涉及所有三个代码路径。在涉及域服务错误的事件期间,了解导致错误的代码路径将大有帮助!

如果我们不知道代码路径,我们很容易在事件调查期间做出毫无结果的猜测。

所以,我们应该将代码路径添加到日志中!以下是我们如何使用 Spring Boot 做到这一点。

为传入的 Web 请求添加代码路径

在 Java 中,SLF4J 日志库提供了 MDC 类(消息诊断上下文)。这个类允许我们向在同一线程中发出的所有日志事件添加自定义字段。

要为每个传入的 Web 请求添加自定义字段,我们需要构建一个拦截器,在每个请求的开头添加 codePath 字段,甚至在我们的 Web 控制器代码执行之前。

我们可以通过实现 HandlerInterceptor 接口来做到这一点:

public class LoggingInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {

        if (request.getHeader("X-CUSTOM-HEADER") != null) {
            MDC.put("codePath", "3rdParty");
        } else {
            MDC.put("codePath", "user");
        }

        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            ModelAndView modelAndView) {
        MDC.remove("codePath");
    }
}

在 preHandle() 方法中,我们调用 MDC.put() 将 codePath 字段添加到所有日志事件中。如果请求包含标识请求来自第三方方系统的标头,我们将代码路径设置为 3rdParty,否则,我们假设请求来自用户的浏览器。

根据应用的不同,这里的逻辑可能会有很大的不同,当然,这只是一个例子。

postHandle() 方法中,我们不应该忘记调用 MDC.remove() 再次删除所有先前设置的字段,否则线程仍会保留这些字段,即使它返回到线程池,以及下一个请求 由该线程提供服务的那些字段可能仍然设置为错误的值。

要激活拦截器,我们需要将其添加到 InterceptorRegistry 中:

@Componentpublic
class WebConfigurer implements WebMvcConfigurer {

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

就是这样。在传入日志事件的线程中发出的所有日志事件现在都具有 codePath 字段。

如果任何请求创建并启动子线程,请确保在新线程生命周期开始时调用 MDC.put()

在计划作业中添加代码路径

在 Spring Boot 中,我们可以通过使用 @Scheduled@EnableScheduling 注解轻松创建计划作业。

要将代码路径添加到日志中,我们需要确保调用 MDC.put() 作为调度方法中的第一件事:

@Componentpublic
class Timer {

    private final DomainService domainService;

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

    public Timer(DomainService domainService) {
        this.domainService = domainService;
    }

    @Scheduled(fixedDelay = 5000)
    void scheduledHello() {
        MDC.put("codePath", "timer");
        logger.info("log event from timer");
        // do some actual work
        MDC.remove("codePath");
    }

}

这样,从执行调度方法的线程发出的所有日志事件都将包含字段 codePath。我们也可以创建我们自己的 @Job 注解或类似的注解来为我们完成这项工作,但这超出了本文的范围。

为了使预定作业的日志更有价值,我们可以添加其他字段:

  • job_status:指示作业是否成功的状态。
  • job_id:已执行作业的 ID。
  • job_records_processed:如果作业进行一些批处理,它可以记录处理的记录数。
  • ……
    通过日志中的这些字段,我们可以在日志服务器获取到很多有用的信息!

将用户 ID 添加到用户启动的日志事件

典型 Web 应用程序中的大部分工作是在来自用户浏览器的 Web 请求中完成的,这些请求会触发应用程序中的线程,为浏览器创建响应。

想象一下发生了一些错误,日志中的堆栈跟踪显示它与特定的用户配置有关。但是我们不知道请求来自哪个用户!

为了缓解这种情况,在用户触发的所有日志事件中包含某种用户 ID 是非常有帮助的

由于我们知道传入的 Web 请求大多直接来自用户的浏览器,因此我们可以在创建的同一个 LoggingInterceptor 中添加 username 字段以添加 codePath 字段:

public class LoggingInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {

        Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

        if (principal instanceof UserDetails) {
            String username = ((UserDetails) principal).getUsername();
            MDC.put("username", username);
        } else {
            String username = principal.toString();
            MDC.put("username", username);
        }

        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            ModelAndView modelAndView) {
        MDC.remove("username");
    }
}

这段代码假设我们使用 Spring Security 来管理对 Web 应用程序的访问。我们使用 SecurityContextHolder 来获取 Principal 并从中提取用户名以将其传递给 MDC.put()

从服务请求的线程发出的每个日志事件现在都将包含用户名字段和用户名。

有了这个字段,我们现在可以过滤特定用户请求的日志。如果用户报告了问题,我们可以根据他们的姓名过滤日志,并极大地减少我们必须查看的日志。

根据规定,您可能希望记录更不透明的用户 ID 而不是用户名。

为错误日志事件添加根本原因

当我们的应用程序出现错误时,我们通常会记录堆栈跟踪。堆栈跟踪帮助我们确定错误的根本原因。如果没有堆栈跟踪,我们将不知道是哪个代码导致了错误!

但是,如果我们想在应用程序中运行错误统计信息,堆栈跟踪是非常笨拙的。假设我们想知道我们的应用程序每天总共记录了多少错误,以及其中有多少是由哪个根本原因异常引起的。我们必须从日志中导出所有堆栈跟踪,并对它们进行一些手动过滤,才能得到该问题的答案!

但是,如果我们将自定义字段 rootCause 添加到每个错误日志事件,我们可以通过该字段过滤日志事件,然后在日志服务器的 UI 中创建不同根本原因的直方图或饼图,甚至无需导出数据。

在 Spring Boot 中执行此操作的一种方法是创建一个 @ExceptionHandle

@ControllerAdvicepublic
class WebExceptionHandler {

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

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public void internalServerError(Exception e) {
        MDC.put("rootCause", getRootCause(e).getClass().getName());
        logger.error("returning 500 (internal server error).", e);
        MDC.remove("rootCause");
    }

    private Throwable getRootCause(Exception e) {
        Throwable rootCause = e;
        while (e.getCause() != null && rootCause.getCause() != rootCause) {
            rootCause = e.getCause();
        }
        return rootCause;
    }

}

我们创建了一个用 @ControllerAdvice 注解的类,这意味着它在我们所有的 web 控制器中都是有效的。

在类中,我们创建了一个用 @ExceptionHandler 注解的方法。对于任何 Web 控制器中出现的异常,都会调用此方法。它将 rootCause MDC 字段设置为导致错误的异常类的完全限定名称,然后记录异常的堆栈跟踪。

就是这样。所有打印堆栈跟踪的日志事件现在都有一个字段 rootCause,我们可以通过这个字段进行过滤以了解我们应用程序中的错误分布。

向所有日志事件添加跟踪 ID

如果我们运行多个服务,例如在微服务环境中,分析错误时事情会很快变得复杂。一个服务调用另一个服务,另一个服务调用再一个服务,并且很难(如果可能的话)跟踪一个服务中的错误到另一个服务中的错误。

跟踪 ID 有助于连接一个服务中的日志事件和另一个服务中的日志事件:

在上面的示例图中,服务 1 被调用并生成跟踪 ID“1234”。然后它调用服务 2 和 3,将相同的跟踪 ID 传播给它们,以便它们可以将相同的跟踪 ID 添加到其日志事件中,从而可以通过搜索特定的跟踪 ID 来连接所有服务的日志事件。

对于每个传出请求,服务 1 还会创建一个唯一的“跨度 ID”。虽然跟踪跨越服务 1 的整个请求/响应周期,但跨度仅跨越一个服务和另一个服务之间的请求/响应周期。

我们可以自己实现这样的跟踪机制,但是有一些跟踪标准和工具可以使用这些标准集成到跟踪系统中,例如 Logz.io 的分布式跟踪功能

我们还是使用标准工具吧。在 Spring Boot 世界中,这就是 Spring Cloud Sleuth,我们可以通过简单地将它添加到我们的 pom.xml,从而把该功能集成到我们的应用程序中:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>2020.0.3</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement><dependencies>
  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-sleuth</artifactId>
  </dependency>
</dependencies>

这会自动将跟踪和跨度 ID 添加到我们的日志中,并在使用支持的 HTTP 客户端时通过请求标头将它们从一个服务传播到下一个服务。您可以在“使用 Spring Cloud Sleuth 在分布式系统中进行跟踪”一文中阅读有关 Spring Cloud Sleuth 的更多信息。

添加某些代码路径的持续时间

我们的应用程序响应请求所需的总持续时间是一个重要的指标。如果速度太慢,用户会感到沮丧。

通常,将请求持续时间作为指标公开并创建显示请求持续时间的直方图和百分位数的仪表板是一个好主意,这样我们就可以一目了然地了解应用程序的健康状况,甚至可能在违反某个阈值时收到警报。

然而,我们并不是一直在查看仪表板,我们可能不仅对总请求持续时间感兴趣,而且对某些代码路径的持续时间感兴趣。在分析日志以调查问题时,了解代码中特定路径执行所需的时间可能是一个重要线索。

在 Java 中,我们可能会这样做:

void callThirdPartyService() throws InterruptedException {
    logger.info("log event from the domain service");
    Instant start=Instant.now();
    Thread.sleep(2000); // simulating an expensive operation
    Duration duration=Duration.between(start,Instant.now());
    MDC.put("thirdPartyCallDuration",String.valueOf(duration.getNano()));
    logger.info("call to third-party service successful!");
    MDC.remove("thirdPartyCallDuration");
}

假设我们正在调用第三方服务并希望将持续时间添加到日志中。使用 Instant.now()Duration.between(),我们计算持续时间,将其添加到 MDC,然后创建日志事件。

这个日志事件现在将包含字段 thirdPartyCallDuration,我们可以在日志中过滤和搜索该字段。 例如,我们可能会搜索这个调用耗时过长的实例。 然后,我们可以使用用户 ID 或跟踪 ID,当这需要特别长的时间时,我们也可以将它们作为日志事件的字段来找出模式。

在Logz.io中查询结构化日志

如果我们按照关于 per-environment logging 的文章中的描述设置了日志记录到 Logz.io,我们现在可以在 Logz.io 提供的 Kibana UI 中查询日志。

错误分布

例如,我们可以查询在 rootCause 字段中具有值的所有日志事件:

__exists__: "rootCause"

这将显示具有根本原因的错误事件列表。

我们还可以在 Logz.io UI 中创建一个可视化来显示给定时间范围内的错误分布:

此图表显示几乎一半的错误是由 ThingyException 引起的,因此检查是否可以以某种方式避免此异常可能是个好主意。如果无法避免,我们应该将其记录在 WARN 而不是 ERROR 上,以保持错误日志的清洁。

跨代码路径的错误分布

例如,假设用户抱怨预定的作业没有正常工作。如果我们在调度方法代码中添加了一个 job_status 字段,我们可以通过那些失败的作业来过滤日志:

job_status: "ERROR"

为了获得更高级的视图,我们可以创建另一个饼图可视化,显示 job_statusrootCause 的分布:

我们现在可以看到大部分预定的作业都失败了!我们应该为此添加一些警报! 我们还可以查看哪些异常是大多数计划作业的根本原因并开始调查。

检查用户的错误

或者,假设用户名为 “user” 的用户提出了一个支持请求,指定了它发生的大致日期和时间。我们可以使用查询 username: user 过滤日志以仅显示该用户的日志,并且可以快速将用户问题的原因归零。

我们还可以扩展查询以仅显示具有 rootCause 的该用户的日志事件,以直接了解何时出了什么问题。

username: "user" AND _exists_: "rootCause"

结构化您的日志

本文仅展示了几个示例,说明我们如何向日志事件添加结构并在查询日志时使用该结构。以后可以在日志中搜索的任何内容都应该是日志事件中的自定义字段。添加到日志事件中的字段在很大程度上取决于我们正在构建的应用程序,所以在编写代码时,一定要考虑哪些信息可以帮助您分析日志。

您可以在 GitHub 上找到本文中讨论的代码示例。

使用 Spring Boot 构可重用的 Mock 模块

【译】本文译自: Building Reusable Mock Modules with Spring Boot – Reflectoring

将代码库分割成松散耦合的模块,每个模块都有一组专门的职责,这不是很好吗?

这意味着我们可以轻松找到代码库中的每个职责来添加或修改代码。也意味着代码库很容易掌握,因为我们一次只需要将一个模块加载到大脑的工作记忆中。

而且,由于每个模块都有自己的 API,这意味着我们可以为每个模块创建一个可重用的模拟。在编写集成测试时,我们只需导入一个模拟模块并调用其 API 即可开始模拟。我们不再需要知道我们模拟的类的每一个细节。

在本文中,我们将着眼于创建这样的模块,讨论为什么模拟整个模块比模拟单个 bean 更好,然后介绍一种简单但有效的模拟完整模块的方法,以便使用 Spring Boot 进行简单的测试设置。

代码示例

本文附有 GitHub 上的工作代码示例。

什么是模块?

当我在本文中谈论“模块”时,我的意思是:

模块是一组高度内聚的类,这些类具有专用的 API 和一组相关的职责。

我们可以将多个模块组合成更大的模块,最后组合成一个完整的应用程序。

一个模块可以通过调用它的 API 来使用另一个模块。

你也可以称它们为“组件”,但在本文中,我将坚持使用“模块”。

如何构建模块?

在构建应用程序时,我建议预先考虑如何模块化代码库。我们的代码库中的自然边界是什么?

我们的应用程序是否需要与外部系统进行通信?这是一个自然的模块边界。我们可以构建一个模块,其职责是与外部系统对话!

我们是否确定了属于一起的用例的功能“边界上下文”?这是另一个很好的模块边界。我们将构建一个模块来实现应用程序的这个功能部分中的用例!

当然,有更多方法可以将应用程序拆分为模块,而且通常不容易找到它们之间的边界。他们甚至可能会随着时间的推移而改变!更重要的是在我们的代码库中有一个清晰的结构,这样我们就可以轻松地在模块之间移动概念!

为了使模块在我们的代码库中显而易见,我建议使用以下包结构:

  • 每个模块都有自己的包

  • 每个模块包都有一个 api 子包,包含所有暴露给其他模块的类

  • 每个模块包都有一个内部子包 internal ,其中包含:

    • 实现 API 公开的功能的所有类
    • 一个 Spring 配置类,它将 bean 提供给实现该 API 所需的 Spring 应用程序上下文
  • 就像俄罗斯套娃一样,每个模块的 internal 子包可能包含带有子模块的包,每个子模块都有自己的 api 和 internal

  • 给定 internal 包中的类只能由该包中的类访问。

这使得代码库非常清晰,易于导航。在我关于清晰架构边界 中阅读有关此代码结构的更多信息,或 示例代码中的一些代码。

这是一个很好的包结构,但这与测试和模拟有什么关系呢?

模拟单个 Bean 有什么问题?

正如我在开始时所说的,我们想着眼于模拟整个模块而不是单个 bean。但是首先模拟单个 bean 有什么问题呢?

让我们来看看使用 Spring Boot 创建集成测试的一种非常常见的方式。

假设我们想为 REST 控制器编写一个集成测试,该控制器应该在 GitHub 上创建一个存储库,然后向用户发送电子邮件。

集成测试可能如下所示:

@WebMvcTest
class RepositoryControllerTestWithoutModuleMocks {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private GitHubMutations gitHubMutations;

    @MockBean
    private GitHubQueries gitHubQueries;

    @MockBean
    private EmailNotificationService emailNotificationService;

  @Test
  void givenRepositoryDoesNotExist_thenRepositoryIsCreatedSuccessfully()
      throws Exception {
    String repositoryUrl = "https://github.com/reflectoring/reflectoring";

    given(gitHubQueries.repositoryExists(...)).willReturn(false);
    given(gitHubMutations.createRepository(...)).willReturn(repositoryUrl);

    mockMvc.perform(post("/github/repository")
      .param("token", "123")
      .param("repositoryName", "foo")
      .param("organizationName", "bar"))
      .andExpect(status().is(200));

    verify(emailNotificationService).sendEmail(...);
    verify(gitHubMutations).createRepository(...);
  }

}

这个测试实际上看起来很整洁,我见过(并编写)了很多类似的测试。但正如人们所说,细节决定成败。

我们使用 @WebMvcTest 注解来设置 Spring Boot 应用程序上下文以测试 Spring MVC 控制器。应用程序上下文将包含让控制器工作所需的所有 bean,仅此而已。

但是我们的控制器在应用程序上下文中需要一些额外的 bean 才能工作,即 GitHubMutationsGitHubQueries、和 EmailNotificationService。因此,我们通过 @MockBean 注解将这些 bean 的模拟添加到应用程序上下文中。

在测试方法中,我们在一对 given() 语句中定义这些模拟的状态,然后调用我们要测试的控制器端点,之后 verify() 在模拟上调用了某些方法。

那么,这个测试有什么问题呢? 我想到了两件主要的事情:

首先,要设置 given()verify() 部分,测试需要知道控制器正在调用模拟 bean 上的哪些方法。这种对实现细节的低级知识使测试容易被修改。每次实现细节发生变化时,我们也必须更新测试。这稀释了测试的价值,并使维护测试成为一件苦差事,而不是“有时是例行公事”。

其次, @MockBean 注解将导致 Spring 为每个测试创建一个新的应用程序上下文(除非它们具有完全相同的字段)。在具有多个控制器的代码库中,这将显着增加测试运行时间。

如果我们投入一点精力来构建上一节中概述的模块化代码库,我们可以通过构建可重用的模拟模块来解决这两个缺点。

让我们通过看一个具体的例子来了解如何实现。

模块化 Spring Boot 应用程序

好,让我们看看如何使用 Spring Boots 实现可重用的模拟模块。

这是示例应用程序的文件夹结构。如果你想跟随,你可以在 GitHub 上找到代码:

├── github
|   ├── api
|   |  ├── <I> GitHubMutations
|   |  ├── <I> GitHubQueries
|   |  └── <C> GitHubRepository
|   └── internal
|      ├── <C> GitHubModuleConfiguration
|      └── <C> GitHubService
├── mail
|   ├── api
|   |  └── <I> EmailNotificationService
|   └── internal
|      ├── <C> EmailModuleConfiguration
|      ├── <C> EmailNotificationServiceImpl
|      └── <C> MailServer
├── rest
|   └── internal
|       └── <C> RepositoryController
└── <C> DemoApplication

该应用程序有 3 个模块:

  • github 模块提供了与 GitHub API 交互的接口,

  • mail 模块提供电子邮件功能,

  • rest 模块提供了一个 REST API 来与应用程序交互。

让我们更详细地研究每个模块。

GitHub 模块

github 模块提供了两个接口(用 <I> 标记)作为其 API 的一部分:

  • GitHubMutations,提供了一些对 GitHub API 的写操作,

  • GitHubQueries,它提供了对 GitHub API 的一些读取操作。

这是接口的样子:

public interface GitHubMutations {

    String createRepository(String token, GitHubRepository repository);

}

public interface GitHubQueries {

    List<String> getOrganisations(String token);

    List<String> getRepositories(String token, String organisation);

    boolean repositoryExists(String token, String repositoryName, String organisation);

}

它还提供类 GitHubRepository,用于这些接口的签名。

在内部, github 模块有类 GitHubService,它实现了两个接口,还有类 GitHubModuleConfiguration,它是一个 Spring 配置,为应用程序上下文贡献一个 GitHubService 实例:

@Configuration
class GitHubModuleConfiguration {

    @Bean
    GitHubService gitHubService() {
        return new GitHubService();
    }

}

由于 GitHubService 实现了 github 模块的整个 API,因此这个 bean 足以使该模块的 API 可用于同一 Spring Boot 应用程序中的其他模块。

Mail 模块

mail 模块的构建方式类似。它的 API 由单个接口 EmailNotificationService 组成:

public interface EmailNotificationService {

    void sendEmail(String to, String subject, String text);

}

该接口由内部 beanEmailNotificationServiceImpl 实现。

请注意,我在 mail 模块中使用的命名约定与在 github 模块中使用的命名约定不同。 github 模块有一个以 *Servicee 结尾的内部类,而 mail 模块有一个 *Service 类作为其 API 的一部分。虽然 github 模块不使用丑陋的 *Impl 后缀,但 mail 模块使用了。

我故意这样做是为了使代码更现实一些。你有没有见过一个代码库(不是你自己写的)在所有地方都使用相同的命名约定?我没有。

但是,如果您像我们在本文中所做的那样构建模块,那实际上并不重要。因为丑陋的 *Impl 类隐藏在模块的 API 后面。

在内部, mail 模块具有 EmailModuleConfiguration 类,它为 Spring 应用程序上下文提供 API 实现:

@Configuration
class EmailModuleConfiguration {

    @Bean
    EmailNotificationService emailNotificationService() {
        return new EmailNotificationServiceImpl();
    }

}

REST 模块

rest 模块由单个 REST 控制器组成:

@RestController
class RepositoryController {

    private final GitHubMutations gitHubMutations;
    private final GitHubQueries gitHubQueries;
    private final EmailNotificationService emailNotificationService;

    // constructor omitted

    @PostMapping("/github/repository")
    ResponseEntity<Void> createGitHubRepository(@RequestParam("token") String token,
            @RequestParam("repositoryName") String repoName, @RequestParam("organizationName") String orgName) {

        if (gitHubQueries.repositoryExists(token, repoName, orgName)) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }
        String repoUrl = gitHubMutations.createRepository(token, new GitHubRepository(repoName, orgName));
        emailNotificationService.sendEmail("user@mail.com", "Your new repository",
                "Here's your new repository: " + repoUrl);

        return ResponseEntity.ok().build();
    }

}

控制器调用 github 模块的 API 来创建一个 GitHub 仓库,然后通过 mail 模块的 API 发送邮件,让用户知道新的仓库。

模拟 GitHub 模块
现在,让我们看看如何为 github 模块构建一个可重用的模拟。我们创建了一个 @TestConfiguration 类,它提供了模块 API 的所有 bean:

@TestConfiguration
public class GitHubModuleMock {

    private final GitHubService gitHubServiceMock = Mockito.mock(GitHubService.class);

    @Bean
    @Primary
    GitHubService gitHubServiceMock() {
        return gitHubServiceMock;
    }

    public void givenCreateRepositoryReturnsUrl(String url) {
        given(gitHubServiceMock.createRepository(any(), any())).willReturn(url);
    }

    public void givenRepositoryExists() {
        given(gitHubServiceMock.repositoryExists(anyString(), anyString(), anyString())).willReturn(true);
    }

    public void givenRepositoryDoesNotExist() {
        given(gitHubServiceMock.repositoryExists(anyString(), anyString(), anyString())).willReturn(false);
    }

    public void assertRepositoryCreated() {
        verify(gitHubServiceMock).createRepository(any(), any());
    }

    public void givenDefaultState(String defaultRepositoryUrl) {
        givenRepositoryDoesNotExist();
        givenCreateRepositoryReturnsUrl(defaultRepositoryUrl);
    }

    public void assertRepositoryNotCreated() {
        verify(gitHubServiceMock, never()).createRepository(any(), any());
    }

}

除了提供一个模拟的 GitHubService bean,我们还向这个类添加了一堆 given*()assert*() 方法。

给定的 given*() 方法允许我们将模拟设置为所需的状态,而 verify*() 方法允许我们在运行测试后检查与模拟的交互是否发生。

@Primary 注解确保如果模拟和真实 bean 都加载到应用程序上下文中,则模拟优先。

模拟 Email 邮件模块

我们为 mail 模块构建了一个非常相似的模拟配置:

@TestConfiguration
public class EmailModuleMock {

    private final EmailNotificationService emailNotificationServiceMock = Mockito.mock(EmailNotificationService.class);

    @Bean
    @Primary
    EmailNotificationService emailNotificationServiceMock() {
        return emailNotificationServiceMock;
    }

    public void givenSendMailSucceeds() {
        // nothing to do, the mock will simply return
    }

    public void givenSendMailThrowsError() {
        doThrow(new RuntimeException("error when sending mail")).when(emailNotificationServiceMock)
                .sendEmail(anyString(), anyString(), anyString());
    }

    public void assertSentMailContains(String repositoryUrl) {
        verify(emailNotificationServiceMock).sendEmail(anyString(), anyString(), contains(repositoryUrl));
    }

    public void assertNoMailSent() {
        verify(emailNotificationServiceMock, never()).sendEmail(anyString(), anyString(), anyString());
    }

}

在测试中使用模拟模块

现在,有了模拟模块,我们可以在控制器的集成测试中使用它们:

@WebMvcTest
@Import({ GitHubModuleMock.class, EmailModuleMock.class })
class RepositoryControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private EmailModuleMock emailModuleMock;

    @Autowired
    private GitHubModuleMock gitHubModuleMock;

    @Test
    void givenRepositoryDoesNotExist_thenRepositoryIsCreatedSuccessfully() throws Exception {

        String repositoryUrl = "https://github.com/reflectoring/reflectoring.github.io";

        gitHubModuleMock.givenDefaultState(repositoryUrl);
        emailModuleMock.givenSendMailSucceeds();

        mockMvc.perform(post("/github/repository").param("token", "123").param("repositoryName", "foo")
                .param("organizationName", "bar")).andExpect(status().is(200));

        emailModuleMock.assertSentMailContains(repositoryUrl);
        gitHubModuleMock.assertRepositoryCreated();
    }

    @Test
    void givenRepositoryExists_thenReturnsBadRequest() throws Exception {

        String repositoryUrl = "https://github.com/reflectoring/reflectoring.github.io";

        gitHubModuleMock.givenDefaultState(repositoryUrl);
        gitHubModuleMock.givenRepositoryExists();
        emailModuleMock.givenSendMailSucceeds();

        mockMvc.perform(post("/github/repository").param("token", "123").param("repositoryName", "foo")
                .param("organizationName", "bar")).andExpect(status().is(400));

        emailModuleMock.assertNoMailSent();
        gitHubModuleMock.assertRepositoryNotCreated();
    }

}

我们使用 @Import 注解将模拟导入到应用程序上下文中。

请注意, @WebMvcTest 注解也会导致将实际模块加载到应用程序上下文中。这就是我们在模拟上使用 @Primary 注解的原因,以便模拟优先。

如何处理行为异常的模块?

模块可能会在启动期间尝试连接到某些外部服务而行为异常。例如, mail 模块可能会在启动时创建一个 SMTP 连接池。当没有可用的 SMTP 服务器时,这自然会失败。这意味着当我们在集成测试中加载模块时,Spring 上下文的启动将失败。
为了使模块在测试期间表现得更好,我们可以引入一个配置属性 mail.enabled。然后,我们使用 @ConditionalOnProperty 注解模块的配置类,以告诉 Spring 如果该属性设置为 false,则不要加载此配置。
现在,在测试期间,只加载模拟模块。

我们现在不是在测试中模拟特定的方法调用,而是在模拟模块上调用准备好的 given*() 方法。这意味着测试不再需要测试对象调用的类的内部知识

执行代码后,我们可以使用准备好的 verify*() 方法来验证是否已创建存储库或已发送邮件。同样,不知道具体的底层方法调用。

如果我们需要另一个控制器中的 githubmail 模块,我们可以在该控制器的测试中使用相同的模拟模块。

如果我们稍后决定构建另一个使用某些模块的真实版本但使用其他模块的模拟版本的集成,则只需使用几个 @Import 注解来构建我们需要的应用程序上下文。

这就是模块的全部思想:我们可以使用真正的模块 A 和模块 B 的模拟,我们仍然有一个可以运行测试的工作应用程序

模拟模块是我们在该模块中模拟行为的中心位置。他们可以将诸如“确保可以创建存储库”之类的高级模拟期望转换为对 API bean 模拟的低级调用。

结论

通过有意识地了解什么是模块 API 的一部分,什么不是,我们可以构建一个适当的模块化代码库,几乎不会引入不需要的依赖项。

由于我们知道什么是 API 的一部分,什么不是,我们可以为每个模块的 API 构建一个专用的模拟。我们不在乎内部,我们只是在模拟 API。

模拟模块可以提供 API 来模拟某些状态并验证某些交互。通过使用模拟模块的 API 而不是模拟每个单独的方法调用,我们的集成测试变得更有弹性以适应变化。

如何在 Spring 中使用事件

【注】本文译自:Spring Events | Baeldung

1. 概述

在本教程中,我们将讨论如何在 Spring 中使用事件

事件是框架中最容易被忽视的功能之一,但也是更有用的功能之一。和 Spring 中的许多其他东西一样,事件发布是 ApplicationContext 提供的功能之一。

有一些简单的指导方针可以遵循:

  • 如果我们使用 Spring Framework 4.2 之前的版本,事件类应该扩展 ApplicationEvent。从 4.2 版本开始,事件类不再需要扩展 ApplicationEvent 类。
  • 发布者应该注入一个 ApplicationEventPublisher 对象。
  • 监听器应实现 ApplicationListener 接口。

    2.自定义事件

    Spring 允许我们创建和发布默认同步的自定义事件。这有一些优点,例如监听器能够参与发布者的事务上下文。

2.1.一个简单的应用程序事件

让我们创建一个简单的事件类——只是一个用于存储事件数据的占位符。

在这种情况下,事件类包含一个 String 消息:

public class CustomSpringEvent extends ApplicationEvent {
    private String message;

    public CustomSpringEvent(Object source, String message) {
        super(source);
        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}

2.2. 发布者

现在让我们创建该事件的发布者。发布者构造事件对象并将其发布给正在收听的任何人。

要发布事件,发布者可以简单地注入 ApplicationEventPublisher 并使用 publishEvent() API:

@Component
public class CustomSpringEventPublisher {
    @Autowired
    private ApplicationEventPublisher applicationEventPublisher;

    public void publishCustomEvent(final String message) {
        System.out.println("Publishing custom event. ");
        CustomSpringEvent customSpringEvent = new CustomSpringEvent(this, message);
        applicationEventPublisher.publishEvent(customSpringEvent);
    }
}

或者,发布者类可以实现
ApplicationEventPublisherAware 接口,这也会在应用程序启动时注入事件发布者。通常,将 @Autowire 注入发布者会更简单。

从 Spring Framework 4.2 开始,ApplicationEventPublisher 接口为 publishEvent(Object event) 方法提供了一个新的重载,该方法接受任何对象作为事件。因此,Spring 事件不再需要扩展 ApplicationEvent

2.3. 监听器

最后,让我们创建监听器。

监听器的唯一要求是是一个 bean 并实现 ApplicationListener 接口:

@Component
public class CustomSpringEventListener implements ApplicationListener<CustomSpringEvent> {
    @Override
    public void onApplicationEvent(CustomSpringEvent event) {
        System.out.println("Received spring custom event - " + event.getMessage());
    }
}

请注意我们的自定义监听器如何使用自定义事件的通用类型进行参数化,这使得 onApplicationEvent() 方法类型安全。这也避免了必须检查对象是否是特定事件类的实例并对其进行转换。

而且,正如已经讨论过的(默认情况下,Spring 事件是同步的), doStuffAndPublishAnEvent() 方法会阻塞,直到所有监听器完成对事件的处理。

3.创建异步事件

在某些情况下,同步发布事件并不是我们真正想要的——我们可能需要异步处理我们的事件

我们可以通过创建一个带有执行程序的
ApplicationEventMulticaster bean 在配置中打开它。

对于我们来说 SimpleAsyncTaskExecutor 很好地实现了这个目的:

@Configuration
public class AsynchronousSpringEventsConfig {
    @Bean(name = "applicationEventMulticaster")
    public ApplicationEventMulticaster simpleApplicationEventMulticaster() {
        SimpleApplicationEventMulticaster eventMulticaster = new SimpleApplicationEventMulticaster();
        eventMulticaster.setTaskExecutor(new SimpleAsyncTaskExecutor());
        return eventMulticaster;
    }
}

事件、发布者和监听器实现与以前相同,但现在监听器将在单独的线程中异步处理事件

4.现有框架事件

Spring 本身发布了各种开箱即用的事件。例如,ApplicationContext 将触发各种框架事件:ContextRefreshedEventContextStartedEventRequestHandledEvent 等。

这些事件为应用程序开发人员提供了一个选项,可以连接到应用程序的生命周期和上下文,并在需要的地方添加他们自己的自定义逻辑。

这是监听上下文刷新的监听器的快速示例:

public class ContextRefreshedListener implements ApplicationListener<ContextRefreshedEvent> {
    @Override
    public void onApplicationEvent(ContextRefreshedEvent cse) {
        System.out.println("Handling context re-freshed event. ");
    }
}

要了解有关现有框架事件的更多信息,请在此处查看我们的下一个教程

5.注解驱动的事件监听器

从 Spring 4.2 开始,事件监听器不需要是实现 ApplicationListener 接口的 bean——它可以通过 @EventListener 注解在托管 bean 的任何 public 方法上注册:

@Component
public class AnnotationDrivenEventListener {
    @EventListener
    public void handleContextStart(ContextStartedEvent cse) {
        System.out.println("Handling context started event.");
    }
}

和以前一样,方法签名声明了它使用的事件类型。

默认情况下,监听器是同步调用的。但是,我们可以通过添加 @Async 注释轻松地使其异步。我们只需要记住在应用程序中启用异步支持

6.泛型支持

也可以使用事件类型中的泛型信息来调度事件。

6.1. 泛型应用程序事件

让我们创建一个泛型事件类型。

在我们的示例中,事件类包含任何内容和 success 状态指示器:

public class GenericSpringEvent<T> {
    private T what;
    protected boolean success;

    public GenericSpringEvent(T what, boolean success) {
        this.what = what;
        this.success = success;
    }
    // ... standard getters
}

请注意 GenericSpringEventCustomSpringEvent 之间的区别。我们现在可以灵活地发布任意事件,并且不再需要从 ApplicationEvent 扩展。

6.2.监听器

现在让我们创建该事件的监听器。

我们可以像以前一样通过实现 ApplicationListener 接口来定义监听器:

@Component
public class GenericSpringEventListener implements ApplicationListener<GenericSpringEvent<String>> {
    @Override
    public void onApplicationEvent(@NonNull GenericSpringEvent<String> event) {
        System.out.println("Received spring generic event - " + event.getWhat());
    }
}

但不幸的是,这个定义要求我们从 ApplicationEvent 类继承 GenericSpringEvent。因此,对于本教程,让我们使用之前讨论过的注释驱动事件监听器。

通过在 @EventListener 注释上定义布尔 SpEL 表达式,也可以使事件监听器有条件。

在这种情况下,只有成功调用 GenericSpringEvent 的 String 对象时才会调用事件处理程序:

@Component
public class GenericSpringEventListener implements ApplicationListener<GenericSpringEvent<String>> {
    @Override
    public void onApplicationEvent(@NonNull GenericSpringEvent<String> event) {
        System.out.println("Received spring generic event - " + event.getWhat());
    }

}
Spring 表达式语言 (SpEL) 是一种强大的表达式语言,在另一篇教程中有详细介绍。

6.3. 发布者

事件发布者与上述类似。但是由于类型擦除,我们需要发布一个事件来解析我们将过滤的泛型参数,例如,类 GenericStringSpringEvent extends GenericSpringEvent

此外,还有一种发布事件的替代方法。如果我们从使用 @EventListener 注解的方法返回一个非空值作为结果,Spring Framework 会将该结果作为新事件发送给我们。此外,通过将多个新事件作为事件处理的结果返回到一个集合中,我们可以发布多个新事件。

7.事务绑定事件

本节是关于使用 @
TransactionalEventListener
注解的。要了解有关事务管理的更多信息,请查看使用 Spring 和 JPA 事务

从 Spring 4.2 开始,框架提供了一个新的 @
TransactionalEventListener
注解,它是 @EventListener 的扩展,它允许将事件的监听器绑定到事务的某个阶段。

可以绑定到以下事务阶段:

  • AFTER_COMMIT(默认)- 用于在事务成功完成时触发事件。
  • AFTER_ROLLBACK – 如果事务已回滚
  • AFTER_COMPLETION – 如果事务已完成AFTER_COMMITAFTER_ROLLBACK 的别名)
  • BEFORE_COMMIT – 用于在事务提交之前触发事件。

这是一个事务性事件监听器的快速示例:

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleCustom(CustomSpringEvent event) {
    System.out.println("Handling event inside a transaction BEFORE COMMIT.");
}

仅当存在事件生产者正在运行且即将提交的事务时,才会调用此监听器。

如果没有事务在运行,则根本不会发送事件,除非我们通过将 fallbackExecution 属性设置为 true 来覆盖它。

8. 结论

在这篇简短的文章中,我们介绍了在 Spring 中处理事件的基础知识,包括创建一个简单的自定义事件、发布它,然后在监听器中处理它。

我们还简要了解了如何在配置中启用事件的异步处理。

然后我们了解了 Spring 4.2 中引入的改进,例如注解驱动的监听器、更好的泛型支持和事件绑定到事务阶段。

与往常一样,本文中提供的代码可在 GitHub 上获得。这是一个基于 Maven 的项目,因此它应该很容易导入和运行。

使用 Spring Boot 和 @SpringBootTest 进行测试

【注】本文译自: Testing with Spring Boot and @SpringBootTest – Reflectoring

使用@SpringBootTest 注解,Spring Boot 提供了一种方便的方法来启动要在测试中使用的应用程序上下文。在本教程中,我们将讨论何时使用 @SpringBootTest 以及何时更好地使用其他工具进行测试。我们还将研究自定义应用程序上下文的不同方法以及如何减少测试运行时间。

 代码示例

本文附有 GitHub 上的工作代码示例。

“使用 Spring Boot 进行测试”系列

本教程是系列的一部分:

  1. 使用 Spring Boot 进行单元测试
  2. 使用 Spring Boot 和 @WebMvcTest 测试 MVC Web Controller
  3. 使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询
  4. 使用 Spring Boot 和 @SpringBootTest 进行测试

集成测试与单元测试

在开始使用 Spring Boot 进行集成测试之前,让我们定义集成测试与单元测试的区别。
单元测试涵盖单个“单元”,其中一个单元通常是单个类,但也可以是组合测试的一组内聚类。
集成测试可以是以下任何一项:

  • 涵盖多个“单元”的测试。它测试两个或多个内聚类集群之间的交互。
  • 覆盖多个层的测试。这实际上是第一种情况的特化,例如可能涵盖业务服务和持久层之间的交互。
  • 涵盖整个应用程序路径的测试。在这些测试中,我们向应用程序发送请求并检查它是否正确响应并根据我们的预期更改了数据库状态。

Spring Boot 提供了 @SpringBootTest 注解,我们可以使用它来创建一个应用程序上下文,其中包含我们对上述所有测试类型所需的所有对象。但是请注意,过度使用 @SpringBootTest 可能会导致测试套件运行时间非常长
因此,对于涵盖多个单元的简单测试,我们应该创建简单的测试,与单元测试非常相似,在单元测试中,我们手动创建测试所需的对象图并模拟其余部分。这样,Spring 不会在每次测试开始时启动整个应用程序上下文。

测试切片

我们可以将我们的 Spring Boot 应用程序作为一个整体来测试、一个单元一个单元地测试、也可以一层一层地测试。使用 Spring Boot 的测试切片注解,我们可以分别测试每一层。
在我们详细研究 @SpringBootTest 注解之前,让我们探索一下测试切片注解,以检查 @SpringBootTest 是否真的是您想要的。
@SpringBootTest 注解加载完整的 Spring 应用程序上下文。相比之下,测试切片注释仅加载测试特定层所需的 bean。正因为如此,我们可以避免不必要的模拟和副作用。

@WebMvcTest

我们的 Web 控制器承担许多职责,例如侦听 HTTP 请求、验证输入、调用业务逻辑、序列化输出以及将异常转换为正确的响应。我们应该编写测试来验证所有这些功能。
@WebMvcTest 测试切片注释将使用刚好足够的组件和配置来设置我们的应用程序上下文,以测试我们的 Web 控制器层。例如,它将设置我们的@Controller@ControllerAdvice、一个 MockMvc bean 和其他一些自动配置
要阅读有关 @WebMvcTest 的更多信息并了解我们如何验证每个职责,请阅读我关于使用 Spring Boot 和 @WebMvcTest 测试 MVC Web 控制器的文章

@WebFluxTest

@WebFluxTest 用于测试 WebFlux 控制器。 @WebFluxTest 的工作方式类似于 @WebMvcTest 注释,不同之处在于它不是 Web MVC 组件和配置,而是启动 WebFlux 组件和配置。其中一个 bean 是 WebTestClient,我们可以使用它来测试我们的 WebFlux 端点。

@DataJpaTest

就像 @WebMvcTest 允许我们测试我们的 web 层一样,@DataJpaTest 用于测试持久层。
它配置我们的实体、存储库并设置嵌入式数据库。现在,这一切都很好,但是,测试我们的持久层意味着什么? 我们究竟在测试什么? 如果查询,那么什么样的查询?要找出所有这些问题的答案,请阅读我关于使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询的文章

@DataJdbcTest

Spring Data JDBC 是 Spring Data 系列的另一个成员。 如果我们正在使用这个项目并且想要测试持久层,那么我们可以使用 @DataJdbcTest 注解 。@DataJdbcTest 会自动为我们配置在我们的项目中定义的嵌入式测试数据库和 JDBC 存储库。
另一个类似的项目是 Spring JDBC,它为我们提供了 JdbcTemplate 对象来执行直接查询。@JdbcTest 注解自动配置测试我们的 JDBC 查询所需的 DataSource 对象。依赖
本文中的代码示例只需要依赖 Spring Boot 的 test starter 和 JUnit Jupiter:

dependencies {
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.junit.jupiter:junit-jupiter:5.4.0')
}

使用 @SpringBootTest 创建 ApplicationContext

@SpringBootTest 在默认情况下开始在测试类的当前包中搜索,然后在包结构中向上搜索,寻找用 @SpringBootConfiguration 注解的类,然后从中读取配置以创建应用程序上下文。这个类通常是我们的主要应用程序类,因为 @SpringBootApplication 注解包括 @SpringBootConfiguration 注解。然后,它会创建一个与在生产环境中启动的应用程序上下文非常相似的应用程序上下文。
我们可以通过许多不同的方式自定义此应用程序上下文,如下一节所述。
因为我们有一个完整的应用程序上下文,包括 web 控制器、Spring 数据存储库和数据源,@SpringBootTest 对于贯穿应用程序所有层的集成测试非常方便:

@ExtendWith(SpringExtension.class)
@SpringBootTest
@AutoConfigureMockMvc
class RegisterUseCaseIntegrationTest {

  @Autowired
  private MockMvc mockMvc;

  @Autowired
  private ObjectMapper objectMapper;

  @Autowired
  private UserRepository userRepository;

  @Test
  void registrationWorksThroughAllLayers() throws Exception {
    UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");

    mockMvc.perform(post("/forums/{forumId}/register", 42L)
            .contentType("application/json")
            .param("sendWelcomeMail", "true")
            .content(objectMapper.writeValueAsString(user)))
            .andExpect(status().isOk());

    UserEntity userEntity = userRepository.findByName("Zaphod");
    assertThat(userEntity.getEmail()).isEqualTo("zaphod@galaxy.net");
  }
}

@ExtendWith
本教程中的代码示例使用 @ExtendWith 注解告诉 JUnit 5 启用 Spring 支持。从 Spring Boot 2.1 开始,我们不再需要加载 SpringExtension,因为它作为元注释包含在 Spring Boot 测试注释中,例如 @DataJpaTest@WebMvcTest@SpringBootTest

在这里,我们另外使用 @AutoConfigureMockMvc 将 MockMvc 实例添加到应用程序上下文中。
我们使用这个 MockMvc 对象向我们的应用程序执行 POST 请求并验证它是否按预期响应。
然后,我们使用应用程序上下文中的 UserRepository 来验证请求是否导致数据库状态发生预期的变化。

自定义应用程序上下文

我们可以有很多种方法来自定义 @SpringBootTest 创建的应用程序上下文。让我们看看我们有哪些选择。

自定义应用上下文时的注意事项
应用程序上下文的每个自定义都是使其与在生产设置中启动的“真实”应用程序上下文不同的另一件事。因此,为了使我们的测试尽可能接近生产,我们应该只定制让测试运行真正需要的东西!

添加自动配置

在上面,我们已经看到了自动配置的作用:

@SpringBootTest
@AutoConfigureMockMvc
class RegisterUseCaseIntegrationTest {
  ...
}

还有很多其他可用的自动配置,每个都可以将其他 bean 添加到应用程序上下文中。以下是文档中其他一些有用的内容:

  • @AutoConfigureWebTestClient:将 WebTestClient 添加到测试应用程序上下文。它允许我们测试服务器端点。
  • @AutoConfigureTestDatabase:这允许我们针对真实数据库而不是嵌入式数据库运行测试。
  • @RestClientTest:当我们想要测试我们的 RestTemplate 时它会派上用场。 它自动配置所需的组件以及一个 MockRestServiceServer 对象,该对象帮助我们模拟来自 RestTemplate 调用的请求的响应。
  • @JsonTest:自动配置 JSON 映射器和类,例如 JacksonTesterGsonTester。使用这些我们可以验证我们的 JSON 序列化/反序列化是否正常工作。

设置自定义配置属性

通常,在测试中需要将一些配置属性设置为与生产设置中的值不同的值:

@SpringBootTest(properties = "foo=bar")
class SpringBootPropertiesTest {

  @Value("${foo}")
  String foo;

  @Test
  void test(){
    assertThat(foo).isEqualTo("bar");
  }
}

如果属性 foo 存在于默认设置中,它将被此测试的值 bar 覆盖。

使用 @ActiveProfiles 外部化属性

如果我们的许多测试需要相同的属性集,我们可以创建一个配置文件 application-<profile>.propertieapplication-<profile>.yml 并通过激活某个配置文件从该文件加载属性:

# application-test.yml
foo: bar
@SpringBootTest
@ActiveProfiles("test")
class SpringBootProfileTest {

  @Value("${foo}")
  String foo;

  @Test
  void test(){
    assertThat(foo).isEqualTo("bar");
  }
}

使用 @TestPropertySource 设置自定义属性

另一种定制整个属性集的方法是使用 @TestPropertySource 注释:

# src/test/resources/foo.properties
foo=bar
@SpringBootTest
@TestPropertySource(locations = "/foo.properties")
class SpringBootPropertySourceTest {

  @Value("${foo}")
  String foo;

  @Test
  void test(){
    assertThat(foo).isEqualTo("bar");
  }
}

foo.properties 文件中的所有属性都加载到应用程序上下文中。@TestPropertySource 还可以 配置更多。

使用 @MockBean 注入模拟

如果我们只想测试应用程序的某个部分而不是从传入请求到数据库的整个路径,我们可以使用 @MockBean 替换应用程序上下文中的某些 bean:

@SpringBootTest
class MockBeanTest {

  @MockBean
  private UserRepository userRepository;

  @Autowired
  private RegisterUseCase registerUseCase;

  @Test
  void testRegister(){
    // given
    User user = new User("Zaphod", "zaphod@galaxy.net");
    boolean sendWelcomeMail = true;
    given(userRepository.save(any(UserEntity.class))).willReturn(userEntity(1L));

    // when
    Long userId = registerUseCase.registerUser(user, sendWelcomeMail);

    // then
    assertThat(userId).isEqualTo(1L);
  }

}

在这种情况下,我们用模拟替换了 UserRepository bean。使用 Mockitogiven 方法,我们指定了此模拟的预期行为,以测试使用此存储库的类。
您可以在我关于模拟的文章中阅读有关 @MockBean 注解的更多信息。

使用 @Import 添加 Bean

如果某些 bean 未包含在默认应用程序上下文中,但我们在测试中需要它们,我们可以使用 @Import 注解导入它们:

package other.namespace;

@Component
public class Foo {
}

@SpringBootTest
@Import(other.namespace.Foo.class)
class SpringBootImportTest {

  @Autowired
  Foo foo;

  @Test
  void test() {
    assertThat(foo).isNotNull();
  }
}

默认情况下,Spring Boot 应用程序包含它在其包和子包中找到的所有组件,因此通常只有在我们想要包含其他包中的 bean 时才需要这样做。

使用 @TestConfiguration 覆盖 Bean

使用 @TestConfiguration,我们不仅可以包含测试所需的其他 bean,还可以覆盖应用程序中已经定义的 bean。在我们关于使用 @TestConfiguration 进行测试的文章中阅读更多相关信息。

创建自定义 @SpringBootApplication

我们甚至可以创建一个完整的自定义 Spring Boot 应用程序来启动测试。如果这个应用程序类与真正的应用程序类在同一个包中,但是在测试源而不是生产源中,@SpringBootTest 会在实际应用程序类之前找到它,并从这个应用程序加载应用程序上下文。
或者,我们可以告诉 Spring Boot 使用哪个应用程序类来创建应用程序上下文:

@SpringBootTest(classes = CustomApplication.class)
class CustomApplicationTest {
}

但是,在执行此操作时,我们正在测试可能与生产环境完全不同的应用程序上下文,因此仅当无法在测试环境中启动生产应用程序时,这才应该是最后的手段。但是,通常有更好的方法,例如使真实的应用程序上下文可配置以排除不会在测试环境中启动的 bean。让我们看一个例子。
假设我们在应用程序类上使用 @EnableScheduling 注解。每次启动应用程序上下文时(即使在测试中),所有 @Scheduled 作业都将启动,并且可能与我们的测试冲突。 我们通常不希望作业在测试中运行,因此我们可以创建第二个没有 @EnabledScheduling 注释的应用程序类,并在测试中使用它。但是,更好的解决方案是创建一个可以使用属性切换的配置类:

@Configuration
@EnableScheduling
@ConditionalOnProperty(
        name = "io.reflectoring.scheduling.enabled",
        havingValue = "true",
        matchIfMissing = true)
public class SchedulingConfiguration {
}

我们已将 @EnableScheduling 注解从我们的应用程序类移到这个特殊的配置类。将属性 io.reflectoring.scheduling.enabled 设置为 false 将导致此类不会作为应用程序上下文的一部分加载:

@SpringBootTest(properties = "io.reflectoring.scheduling.enabled=false")
class SchedulingTest {

  @Autowired(required = false)
  private SchedulingConfiguration schedulingConfiguration;

  @Test
  void test() {
    assertThat(schedulingConfiguration).isNull();
  }
}

我们现在已经成功地停用了测试中的预定作业。属性 io.reflectoring.scheduling.enabled 可以通过上述任何方式指定。

为什么我的集成测试这么慢?

包含大量 @SpringBootTest 注释测试的代码库可能需要相当长的时间才能运行。Spring 的测试支持 足够智能,只创建一次应用上下文并在后续测试中重复使用,但是如果不同的测试需要不同的应用上下文,它仍然会为每个测试创建一个单独的上下文,这需要一些时间来完成每个测试。
上面描述的所有自定义选项都会导致 Spring 创建一个新的应用程序上下文。因此,我们可能希望创建一个配置并将其用于所有测试,以便可以重用应用程序上下文。
如果您对测试花费在设置和 Spring 应用程序上下文上的时间感兴趣,您可能需要查看 JUnit Insights,它可以包含在 Gradle 或 Maven 构建中,以生成关于 JUnit 5 如何花费时间的很好的报告。

结论

@SpringBootTest 是一种为测试设置应用程序上下文的非常方便的方法,它非常接近我们将在生产中使用的上下文。有很多选项可以自定义此应用程序上下文,但应谨慎使用它们,因为我们希望我们的测试尽可能接近生产运行。
如果我们想在整个应用程序中进行测试,@SpringBootTest 会带来最大的价值。为了仅测试应用程序的某些切片或层,我们还有其他选项可用。
本文中使用的示例代码可在 github 上找到。

使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询

【注】本文译自: Testing JPA Queries with Spring Boot and @DataJpaTest – Reflectoring

除了单元测试,集成测试在生产高质量的软件中起着至关重要的作用。一种特殊的集成测试处理我们的代码和数据库之间的集成。
通过 @DataJpaTest 注释,Spring Boot 提供了一种便捷的方法来设置一个具有嵌入式数据库的环境,以测试我们的数据库查询。
在本教程中,我们将首先讨论哪些类型的查询值得测试,然后讨论创建用于测试的数据库模式和数据库状态的不同方法。

 代码示例

本文附有 GitHub 上的工作代码示例

“使用 Spring Boot 进行测试”系列

本教程是系列的一部分:

  1. 使用 Spring Boot 进行单元测试
  2. 使用 Spring Boot 和 @WebMvcTest 测试 MVC Web Controller
  3. 使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询
  4. 使用 Spring Boot 和 @SpringBootTest 进行测试

依赖

在本教程中,除了通常的 Spring Boot 依赖项之外,我们使用 JUnit Jupiter 作为我们的测试框架,使用 H2 作为内存数据库。

dependencies {
  compile('org.springframework.boot:spring-boot-starter-data-jpa')
  compile('org.springframework.boot:spring-boot-starter-web')
  runtime('com.h2database:h2')
  testCompile('org.springframework.boot:spring-boot-starter-test')
  testCompile('org.junit.jupiter:junit-jupiter-engine:5.2.0')
}

测试什么?

首先要回答我们自己的问题是我们需要测试什么。 让我们考虑一个负责 UserEntity 对象的 Spring Data 存储库:

interface UserRepository extends CrudRepository<UserEntity, Long> {
    // query methods
}

我们有不同的选项来创建查询。让我们详细看看其中的一些,以确定我们是否应该用测试来覆盖它们。

推断查询

第一个选项是创建一个推断查询:

UserEntity findByName(String name);

我们不需要告诉 Spring Data 要做什么,因为它会自动从方法名称的名称推断 SQL 查询。
这个特性的好处是 Spring Data 还会在启动时自动检查查询是否有效。如果我们将方法重命名为 findByFoo() 并且 UserEntity 没有属性 foo ,Spring Data 会向我们抛出一个异常来指出这一点:

org.springframework.data.mapping.PropertyReferenceException:
  No property foo found for type UserEntity!

因此,只要我们至少有一个测试尝试在我们的代码库中启动 Spring 应用程序上下文,我们就不需要为我们的推断查询编写额外的测试。

请注意,对于从 findByNameAndRegistrationDateBeforeAndEmailIsNotNull() 等长方法名称推断出的查询,情况并非如此。这个方法名很难掌握,也很容易出错,所以我们应该测试它是否真的符合我们的预期。

话虽如此,将此类方法重命名为更短、更有意义的名称并添加 @Query 注释以提供自定义 JPQL 查询是一种很好的做法。

使用 @Query 自定义 JPQL 查询

如果查询变得更复杂,提供自定义 JPQL 查询是有意义的:

@Query("select u from UserEntity u where u.name = :name")
UserEntity findByNameCustomQuery(@Param("name") String name);

与推断查询类似,我们可以免费对这些 JPQL 查询进行有效性检查。使用 Hibernate 作为我们的 JPA 提供者,如果发现无效查询,我们将在启动时得到一个 QuerySyntaxException

org.hibernate.hql.internal.ast.QuerySyntaxException:
unexpected token: foo near line 1, column 64 [select u from ...]

但是,自定义查询比通过单个属性查找条目要复杂得多。例如,它们可能包括与其他表的连接或返回复杂的 DTO 而不是实体。
那么,我们应该为自定义查询编写测试吗?令人不满意的答案是,我们必须自己决定查询是否复杂到需要测试。

使用 @Query 的本地查询

另一种方法是使用本地查询

@Query(
  value = "select * from user as u where u.name = :name",
  nativeQuery = true)
UserEntity findByNameNativeQuery(@Param("name") String name);

我们没有指定 JPQL 查询(它是对 SQL 的抽象),而是直接指定一个 SQL 查询。此查询可能使用特定数据库的 SQL 方言。
需要注意的是,Hibernate 和 Spring Data 都不会在启动时验证本地查询。由于查询可能包含特定于数据库的 SQL,因此 Spring Data 或 Hibernate 无法知道要检查什么。
因此,本地查询是集成测试的主要候选者。但是,如果他们真的使用特定数据库的 SQL,那么这些测试可能不适用于嵌入式内存数据库,因此我们必须在后台提供一个真实的数据库(比如,在持续集成管道中按需设置的 docker 容器中)。

@DataJpaTest 简介

为了测试 Spring Data JPA 存储库或任何其他与 JPA 相关的组件,Spring Boot 提供了 @DataJpaTest 注解。我们可以将它添加到单元测试中,它将设置一个 Spring 应用程序上下文:

@ExtendWith(SpringExtension.class)
@DataJpaTest
class UserEntityRepositoryTest {

  @Autowired private DataSource dataSource;
  @Autowired private JdbcTemplate jdbcTemplate;
  @Autowired private EntityManager entityManager;
  @Autowired private UserRepository userRepository;

  @Test
  void injectedComponentsAreNotNull(){
    assertThat(dataSource).isNotNull();
    assertThat(jdbcTemplate).isNotNull();
    assertThat(entityManager).isNotNull();
    assertThat(userRepository).isNotNull();
  }
}

@ExtendWith
本教程中的代码示例使用 @ExtendWith 注解告诉 JUnit 5 启用 Spring 支持。从 Spring Boot 2.1 开始,我们不再需要加载 SpringExtension,因为它作为元注解包含在 Spring Boot 测试注解中,例如 @DataJpaTest、@WebMvcTest 和 @SpringBootTest。本教程中的代码示例使用 @ExtendWith 注解告诉 JUnit 5 启用 Spring 支持。从 Spring Boot 2.1 开始,我们不再需要加载 SpringExtension,因为它作为元注解包含在 Spring Boot 测试注解中,例如 @DataJpaTest@WebMvcTest@SpringBootTest

这样创建的应用程序上下文将不包含我们的 Spring Boot 应用程序所需的整个上下文,而只是它的一个“切片”,其中包含初始化任何 JPA 相关组件(如我们的 Spring Data 存储库)所需的组件。
例如,如果需要,我们可以将 DataSource@JdbcTemplate@EntityManage 注入我们的测试类。此外,我们可以从我们的应用程序中注入任何 Spring Data 存储库。上述所有组件将自动配置为指向嵌入式内存数据库,而不是我们可能在 application.propertiesapplication.yml 文件中配置的“真实”数据库。
请注意,默认情况下,包含所有这些组件(包括内存数据库)的应用程序上下文在所有 @DataJpaTest 注解的测试类中的所有测试方法之间共享。
这就是为什么在默认情况下每个测试方法都在自己的事务中运行的原因,该事务在方法执行后回滚。这样,数据库状态在测试之间保持原始状态,并且测试保持相互独立。

创建数据库模式

在我们可以测试对数据库的任何查询之前,我们需要创建一个 SQL 模式来使用。让我们看看一些不同的方法来做到这一点。

使用 Hibernate ddl-auto

默认情况下,@DataJpaTest 会配置 Hibernate 为我们自动创建数据库模式。对此负责的属性是 spring.jpa.hibernate.ddl-auto,Spring Boot 默认将其设置为 create-drop,这意味着模式在运行测试之前创建并在测试执行后删除。
因此,如果我们对 Hibernate 为我们创建模式感到满意,我们就不必做任何事情。

使用 schema.sql

Spring Boot 支持在应用程序启动时执行自定义 schema.sql 文件。
如果 Spring 在类路径中找到 schema.sql 文件,则将针对数据源执行该文件。 这会覆盖上面讨论的 Hibernate 的 ddl-auto 配置。
我们可以使用属性 spring.datasource.initialization-mode 控制是否应该执行 schema.sql。默认值是嵌入的,这意味着它只会对嵌入的数据库执行(即在我们的测试中)。如果我们将其设置为 always,它将始终执行。
以下日志输出确认文件已被执行:

Executing SQL script from URL [file:.../out/production/resources/schema.sql]

设置 Hibernate 的 ddl-auto 配置以在使用脚本初始化架构时进行验证是有意义的,以便 Hibernate 在启动时检查创建的模式是否与实体类匹配:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class SchemaSqlTest {
  ...
}

使用 Flyway

Flyway 是一种数据库迁移工具,允许指定多个 SQL 脚本来创建数据库模式。它会跟踪目标数据库上已经执行了这些脚本中的哪些脚本,以便只执行之前没有执行过的脚本。
要激活 Flyway,我们只需要将依赖项放入我们的 build.gradle 文件中(如果我们使用 Maven,则类似):

compile('org.flywaydb:flyway-core')

如果我们没有专门配置 Hibernate 的 ddl-auto 配置,它会自动退出,因此 Flyway 具有优先权,并且默认情况下会针对我们的内存数据库测试执行它在文件夹 src/main/resources/db/migration 中找到的所有 SQL 脚本。
同样,将 ddl-auto 设置为 validate 是有意义的,让 Hibernate 检查 Flyway 生成的模式是否符合我们的 Hibernate 实体的期望:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class FlywayTest {
  ...
}

在测试中使用 Flyway 的价值

如果我们在生产中使用 Flyway,也能在上面描述的那样在 JPA 测试中使用它,那就太好了。只有这样我们才能在测试时知道 flyway 脚本按预期工作。
但是,这仅适用于脚本包含在生产数据库和测试中使用的内存数据库(我们的示例中为 H2 数据库)上都有效的 SQL。如果不是这种情况,我们必须在我们的测试中禁用 Flyway,方法是将 spring.flyway.enabled 属性设置为 false,并将 spring.jpa.hibernate.ddl-auto 属性设置为 create-drop 以让 Hibernate 生成模式。
无论如何,让我们确保将 ddl-auto 属性在生产配置文件中设置为 validate!这是我们抵御 Flyway 脚本错误的最后一道防线!无论如何,让我们确保将 ddl-auto 属性在生产配置文件中设置为 validate!这是我们抵御 Flyway 脚本错误的最后一道防线!

使用 Liquibase

Liquibase 是另一种数据库迁移工具,其工作方式类似于 Flyway,但支持除 SQL 之外的其他输入格式。例如,我们可以提供定义数据库架构的 YAML 或 XML 文件。
我们只需添加依赖项即可激活它:

compile('org.liquibase:liquibase-core')

默认情况下,Liquibase 将自动创建在 src/main/resources/db/changelog/db.changelog-master.yaml 中定义的模式。
同样,设置 ddl-autovalidate 是有意义的:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class LiquibaseTest {
  ...
}

在测试中使用 Liquibase 的价值

由于 Liquibase 允许多种输入格式充当 SQL 上的抽象层,因此即使它们的 SQL 方言不同,也可以跨多个数据库使用相同的脚本。这使得在我们的测试和生产中使用相同的 Liquibase 脚本成为可能。
不过,YAML 格式非常敏感,而且我最近在维护大型 YAML 文件集合时遇到了麻烦。这一点,以及尽管我们实际上必须为不同的数据库编辑这些文件的抽象,最终导致转向 Flyway。

填充数据库

现在我们已经为我们的测试创建了一个数据库模式,我们终于可以开始实际的测试了。在数据库查询测试中,我们通常会向数据库添加一些数据,然后验证我们的查询是否返回正确的结果。
同样,有多种方法可以将数据添加到我们的内存数据库中,所以让我们逐一讨论。

使用 data.sql

schema.sql 类似,我们可以使用包含插入语句的 data.sql 文件来填充我们的数据库。上述规则同样适用。

可维护性

data.sql 文件迫使我们将所有 insert 语句放在一个地方。每一个测试都将依赖于这个脚本来设置数据库状态。这个脚本很快就会变得非常大并且难以维护。如果有需要冲突数据库状态的测试怎么办?
因此,应谨慎考虑这种方法。

手动插入实体

为每个测试创建特定数据库状态的最简单方法是在运行被测查询之前在测试中保存一些实体:

@Test
void whenSaved_thenFindsByName() {
  userRepository.save(new UserEntity(
          "Zaphod Beeblebrox",
          "zaphod@galaxy.net"));
  assertThat(userRepository.findByName("Zaphod Beeblebrox")).isNotNull();
}

这对于上面示例中的简单实体来说很容易。但在实际项目中,这些实体的构建和与其他实体的关系通常要复杂得多。此外,如果我们想测试比 findByName 更复杂的查询,很可能我们需要创建比单个实体更多的数据。这很快变得非常令人厌烦。
控制这种复杂性的一种方法是创建工厂方法,可能结合 Objectmother 和 Builder 模式。
在 Java 代码中“手动”对数据库进行编程的方法比其他方法有很大的优势,因为它是重构安全的。代码库中的更改会导致我们的测试代码中出现编译错误。在所有其他方法中,我们必须运行测试才能收到有关重构导致的潜在错误的通知。使用

Spring DBUnit

DBUnit 是一个支持将数据库设置为某种状态的库。Spring DBUnit 将 DBUnit 与 Spring 集成在一起,因此它可以自动与 Spring 的事务等一起工作。
要使用它,我们需要向 Spring DBUnit 和 DBUnit 添加依赖项:

compile('com.github.springtestdbunit:spring-test-dbunit:1.3.0')
compile('org.dbunit:dbunit:2.6.0')

然后,对于每个测试,我们可以创建一个包含所需数据库状态的自定义 XML 文件:

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <user
        id="1"
        name="Zaphod Beeblebrox"
        email="zaphod@galaxy.net"
    />
</dataset>

默认情况下,XML 文件(我们将其命名为 createUser.xml)位于测试类旁边的类路径中。
在测试类中,我们需要添加两个 TestExecutionListeners 来启用 DBUnit 支持。要设置某个数据库状态,我们可以在测试方法上使用 @DatabaseSetup

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        TransactionDbUnitTestExecutionListener.class
})
class SpringDbUnitTest {

  @Autowired
  private UserRepository userRepository;

  @Test
  @DatabaseSetup("createUser.xml")
  void whenInitializedByDbUnit_thenFindsByName() {
    UserEntity user = userRepository.findByName("Zaphod Beeblebrox");
    assertThat(user).isNotNull();
  }
}

对于更改数据库状态的测试查询,我们甚至可以使用 @ExpectedDatabase 来定义数据库在测试预期处于的状态。
但是请注意,自 2016 年以来,Spring DBUnit 没有再维护

@DatabaseSetup 不起作用?

在我的测试中,我遇到了 @DatabaseSetup 注释被默默忽略的问题。原来有一个 ClassNotFoundException 因为找不到某些 DBUnit 类。不过,这个异常被吞了。
原因是我忘记包含对 DBUnit 的依赖,因为我认为 Spring Test DBUnit 可递进地含它。因此,如果您遇到相同的问题,请检查您是否包含了这两个依赖项。

使用 @Sql

一个非常相似的方法是使用 Spring 的 @Sql 注解。我们没有使用 XML 来描述数据库状态,而是直接使用 SQL:

INSERT INTO USER
            (id,
             NAME,
             email)
VALUES      (1,
             'Zaphod Beeblebrox',
             'zaphod@galaxy.net');

在我们的测试中,我们可以简单地使用 @Sql 注解来引用 SQL 文件来填充数据库:

@ExtendWith(SpringExtension.class)
@DataJpaTest
class SqlTest {

  @Autowired
  private UserRepository userRepository;

  @Test
  @Sql("createUser.sql")
  void whenInitializedByDbUnit_thenFindsByName() {
    UserEntity user = userRepository.findByName("Zaphod Beeblebrox");
    assertThat(user).isNotNull();
  }

}

如果我们需要多个脚本,我们可以使用 @SqlGroup 来组合它们。

结论

为了测试数据库查询,我们需要创建模式并用一些数据填充它的方法。由于测试应该相互独立,因此最好对每个测试分别执行此操作。
对于简单的测试和简单的数据库实体,通过创建和保存 JPA 实体手动创建状态就足够了。对于更复杂的场景,@DatabaseSetup@Sql 提供了一种在 XML 或 SQL 文件中外部化数据库状态的方法。

使用 Spring Boot 和 @WebMvcTest 测试 MVC Web Controller

【注】本文译自: Testing MVC Web Controllers with Spring Boot and @WebMvcTest – Reflectoring

在有关使用 Spring Boot 进行测试的系列的第二部分中,我们将了解 Web 控制器。首先,我们将探索 Web 控制器的实际作用,这样我们就可以构建涵盖其所有职责的测试。
然后,我们将找出如何在测试中涵盖这些职责。只有涵盖了这些职责,我们才能确保我们的控制器在生产环境中按预期运行。

 代码示例

本文附有 GitHub 上的工作代码示例。

“使用 Spring Boot 进行测试”系列

本教程是系列的一部分:

  1. 使用 Spring Boot 进行单元测试
  2. 使用 Spring Boot 和 @WebMvcTest 测试 MVC Web Controller
  3. 使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询
  4. 使用 Spring Boot 和 @SpringBootTest 进行测试

依赖

我们将使用 JUnit Jupiter (JUnit 5) 作为测试框架,使用 Mockito 进行模拟,使用 AssertJ 来创建断言,使用 Lombok 来减少样板代码:

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.junit.jupiter:junit-jupiter:5.4.0')
    testCompile('org.mockito:mockito-junit-jupiter:2.23.0')
}

AssertJ 和 Mockito 跟随 spring-boot-starter-test 依赖自动获得。

Web 控制器的职责

让我们从一个典型的 REST 控制器开始:

@RestController
@RequiredArgsConstructor
class RegisterRestController {
    private final RegisterUseCase registerUseCase;

    @PostMapping("/forums/{forumId}/register")
    UserResource register(@PathVariable("forumId") Long forumId, @Valid @RequestBody UserResource userResource,
            @RequestParam("sendWelcomeMail") boolean sendWelcomeMail) {

        User user = new User(userResource.getName(), userResource.getEmail());
        Long userId = registerUseCase.registerUser(user, sendWelcomeMail);

        return new UserResource(userId, user.getName(), user.getEmail());
    }

}

控制器方法用 @PostMapping 注解来定义它应该侦听的 URL、HTTP 方法和内容类型。
它通过用 @PathVariable@RequestBody@RequestParam 注解的参数获取输入,这些参数会从传入的 HTTP 请求中自动填充。
参数可以使用 @Valid 进行注解,以指示 Spring 应该对它们 bean 验证
然后控制器使用这些参数,调用业务逻辑返回一个普通的 Java 对象,默认情况下该对象会自动映射到 JSON 并写入 HTTP 响应体。
这里有很多 spring 魔法。总之,对于每个请求,控制器通常会执行以下步骤:

# 职责 描述
1. 监听 HTTP 请求 控制器应该响应某些 URL、HTTP 方法和内容类型。
2. 反序列化输入 控制器应该解析传入的 HTTP 请求并根据 URL、HTTP 请求参数和请求正文中的变量创建 Java 对象,以便我们可以在代码中使用它们。
3. 验证输入 控制器是防止错误输入的第一道防线,因此它是我们可以验证输入的地方。
4. 调用业务逻辑 解析输入后,控制器必须将输入转换为业务逻辑期望的模型并将其传递给业务逻辑。
5. 序列化输出 控制器获取业务逻辑的输出并将其序列化为 HTTP 响应。
6. 转换异常 如果在某个地方发生异常,控制器应将其转换为对用户有意义的错误消息和 HTTP 状态。

控制器显然有很多工作要做!
我们应该注意不要添加更多的职责,比如执行业务逻辑。否则,我们的控制器测试将变得臃肿且无法维护。
我们将如何编写有意义的测试,涵盖所有这些职责?

单元测试还是集成测试?

我们写单元测试吗?还是集成测试?到底有什么区别?让我们讨论这两种方法并决定其中一种。
在单元测试中,我们将单独测试控制器。这意味着我们将实例化一个控制器对象,模拟业务逻辑,然后调用控制器的方法并验证响应。
这对我们有用吗?让我们检查一下可以单独的单元测试中涵盖上面确定的 6 个职责中的哪一个:

# 职责 可以在单元测试中涵盖吗
1. 监听 HTTP 请求 ❌ 不,因为单元测试不会评估 @PostMapping 注解和指定 HTTP 请求属性的类似注解。
2. 反序列化输入 ❌ 不,因为像@RequestParam 和 @PathVariable 这样的注释不会被评估。相反,我们将输入作为 Java 对象提供,从而有效地跳过 HTTP 请求的反序列化。
3. 验证输入 ❌ 不依赖于 bean 验证,因为不会评估 @Valid 注释。
4. 调用业务逻辑 ✔ 是的,因为我们可以验证是否使用预期的参数调用了模拟的业务逻辑。
5. 序列化输出 ❌ 不能,因为我们只能验证输出的 Java 版本,而不能验证将生成的 HTTP 响应。
6. 转换异常 ❌ 不可以。我们可以检查是否引发了某个异常,但不能检查它是否被转换为某个 JSON 响应或 HTTP 状态代码。

与 Spring 的集成测试会启动一个包含我们需要的所有 bean 的 Spring 应用程序上下文。这包括负责侦听某些 URL、与 JSON 之间进行序列化和反序列化以及将异常转换为 HTTP 的框架 bean。这些 bean 将评估简单单元测试会忽略的注释。总之,简单的单元测试不会覆盖 HTTP 层。所以,我们需要在我们的测试中引入 Spring 来为我们做 HTTP 魔法。因此,我们正在构建一个集成测试来测试我们的控制器代码和 Spring 为 HTTP 支持提供的组件之间的集成。
那么,我们该怎么做呢?

使用 @WebMvcTest 验证控制器职责

Spring Boot 提供了 @WebMvcTest 注释来启动一个应用程序上下文,该上下文只包含测试 Web 控制器所需的 bean:

@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = RegisterRestController.class)
class RegisterRestControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private RegisterUseCase registerUseCase;

  @Test
  void whenValidInput_thenReturns200() throws Exception {
    mockMvc.perform(...);
  }
}

@ExtendWith
本教程中的代码示例使用 @ExtendWith 批注告诉 JUnit 5 启用 Spring 支持。从 Spring Boot 2.1 开始,我们不再需要加载 SpringExtension,因为它作为元注释包含在 Spring Boot 测试注解中,例如 @DataJpaTest@WebMvcTest@SpringBootTest

我们现在可以 @Autowire 从应用程序上下文中获取我们需要的所有 bean。Spring Boot 自动提供了像 ObjectMapper 这样的 bean 来映射到 JSON 和一个 MockMvc 实例来模拟 HTTP 请求。
我们使用 @MockBean 来模拟业务逻辑,因为我们不想测试控制器和业务逻辑之间的集成,而是控制器和 HTTP 层之间的集成。@MockBean 自动用 Mockito 模拟替换应用程序上下文中相同类型的 bean。
您可以在我关于模拟的文章中阅读有关 @MockBean 注解的更多信息。

使用带或不带 controllers 参数的 @WebMvcTest
通过在上面的示例中将 controllers 参数设置为 RegisterRestController.class,我们告诉 Spring Boot 将为此测试创建的应用程序上下文限制为给定的控制器 bean 和 Spring Web MVC 所需的一些框架 bean。我们可能需要的所有其他 bean 必须单独包含或使用 @MockBean 模拟。
如果我们不使用 controllers 参数,Spring Boot 将在应用程序上下文中包含所有控制器。因此,我们需要包含或模拟掉任何控制器所依赖的所有 bean。这使得测试设置更加复杂,具有更多的依赖项,但节省了运行时间,因为所有控制器测试都将重用相同的应用程序上下文。
我倾向于将控制器测试限制在最窄的应用程序上下文中,以使测试独立于我在测试中甚至不需要的 bean,即使 Spring Boot 必须为每个单独的测试创建一个新的应用程序上下文。

让我们来回顾一下每个职责,看看我们如何使用 MockMvc 来验证每一个职责,以便构建我们力所能及的最好的集成测试。

1. 验证 HTTP 请求匹配

验证控制器是否侦听某个 HTTP 请求非常简单。我们只需调用 MockMvcperform() 方法并提供我们要测试的 URL:

mockMvc.perform(post("/forums/42/register")
    .contentType("application/json"))
    .andExpect(status().isOk());

除了验证控制器对特定 URL 的响应之外,此测试还验证正确的 HTTP 方法(在我们的示例中为 POST)和正确的请求内容类型。我们上面看到的控制器会拒绝任何具有不同 HTTP 方法或内容类型的请求。
请注意,此测试仍然会失败,因为我们的控制器需要一些输入参数。
更多匹配 HTTP 请求的选项可以在 MockHttpServletRequestBuilder 的 Javadoc 中找到。

2. 验证输入序列化

为了验证输入是否成功序列化为 Java 对象,我们必须在测试请求中提供它。输入可以是请求正文的 JSON 内容 (@RequestBody)、URL 路径中的变量 (@PathVariable) 或 HTTP 请求参数 (@RequestParam):

@Test
void whenValidInput_thenReturns200() throws Exception {
  UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");

   mockMvc.perform(post("/forums/{forumId}/register", 42L)
        .contentType("application/json")
        .param("sendWelcomeMail", "true")
        .content(objectMapper.writeValueAsString(user)))
        .andExpect(status().isOk());
}

我们现在提供路径变量 forumId、请求参数 sendWelcomeMail 和控制器期望的请求正文。请求正文是使用 Spring Boot 提供的 ObjectMapper 生成的,将 UserResource 对象序列化为 JSON 字符串。
如果测试结果为绿色,我们现在知道控制器的 register() 方法已将这些参数作为 Java 对象接收,并且它们已从 HTTP 请求中成功解析。

3. 验证输入验证

假设 UserResource 使用 @NotNull 注释来拒绝 null 值:

@Value
public class UserResource {

    @NotNull
    private final String name;

    @NotNull
    private final String email;

}

当我们@Valid 注解添加到方法参数时,Bean 验证会自动触发,就像我们在控制器中使用 userResource 参数所做的那样。因此,对于快乐路径(即验证成功时),我们在上一节中创建的测试就足够了。
如果我们想测试验证是否按预期失败,我们需要添加一个测试用例,在该用例中我们将无效的 UserResource JSON 对象发送到控制器。然后我们期望控制器返回 HTTP 状态 400(错误请求):

@Test
void whenNullValue_thenReturns400() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

  mockMvc.perform(post("/forums/{forumId}/register", 42L)
      ...
      .content(objectMapper.writeValueAsString(user)))
      .andExpect(status().isBadRequest());
}

根据验证对应用程序的重要性,我们可能会为每个可能的无效值添加这样的测试用例。但是,这会很快增加很多测试用例,因此您应该与您的团队讨论您希望如何处理项目中的验证测试。

4. 验证业务逻辑调用

接下来,我们要验证业务逻辑是否按预期调用。在我们的例子中,业务逻辑由 RegisterUseCase 接口提供,并需要一个 User 对象和一个 boolean 值作为输入:

interface RegisterUseCase {
    Long registerUser(User user, boolean sendWelcomeMail);
}

我们希望控制器将传入的 UserResource 对象转换为 User 并将此对象传递给 registerUser() 方法。
为了验证这一点,我们可以要求 RegisterUseCase 模拟,它已使用 @MockBean 注解注入到应用程序上下文中:

@Test
void whenValidInput_thenMapsToBusinessModel() throws Exception {
  UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");
  mockMvc.perform(...);

  ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
  verify(registerUseCase, times(1)).registerUser(userCaptor.capture(), eq(true));
  assertThat(userCaptor.getValue().getName()).isEqualTo("Zaphod");
  assertThat(userCaptor.getValue().getEmail()).isEqualTo("zaphod@galaxy.net");
}

在执行了对控制器的调用之后,我们使用 ArgumentCaptor 来捕获传递给 RegisterUseCase.registerUser()User 对象并断言它包含预期值。
调用 verify 检查 registerUser() 是否被调用过一次。
请注意,如果我们对 User 对象进行大量断言,我们可以 创建自己的自定义 Mockito 断言方法 以获得更好的可读性。

5. 验证输出序列化

调用业务逻辑后,我们希望控制器将结果映射到 JSON 字符串并将其包含在 HTTP 响应中。在我们的例子中,我们希望 HTTP 响应正文包含一个有效的 JSON 格式的 UserResource 对象:

@Test
void whenValidInput_thenReturnsUserResource() throws Exception {
  MvcResult mvcResult = mockMvc.perform(...)
      ...
      .andReturn();

  UserResource expectedResponseBody = ...;
  String actualResponseBody = mvcResult.getResponse().getContentAsString();

  assertThat(actualResponseBody).isEqualToIgnoringWhitespace(
              objectMapper.writeValueAsString(expectedResponseBody));
}

要对响应主体进行断言,我们需要使用 andReturn() 方法将 HTTP 交互的结果存储在 MvcResult 类型的变量中。
然后我们可以从响应正文中读取 JSON 字符串,并使用 isEqualToIgnoringWhitespace() 将其与预期的字符串进行比较。我们可以使用 Spring Boot 提供的 ObjectMapper 从 Java 对象构建预期的 JSON 字符串。
请注意,我们可以通过使用自定义的 ResultMatcher 使其更具可读性,稍后对此加以描述

6. 验证异常处理

通常,如果发生异常,控制器应该返回某个 HTTP 状态。400 — 如果请求有问题,500 — 如果出现异常,等等。
默认情况下,Spring 会处理大多数这些情况。但是,如果我们有自定义异常处理,我们想测试它。假设我们想要返回一个结构化的 JSON 错误响应,其中包含请求中每个无效字段的字段名称和错误消息。我们会像这样创建一个 @ControllerAdvice

@ControllerAdvice
class ControllerExceptionHandler {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    ErrorResult handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        ErrorResult errorResult = new ErrorResult();
        for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
            errorResult.getFieldErrors()
                    .add(new FieldValidationError(fieldError.getField(), fieldError.getDefaultMessage()));
        }
        return errorResult;
    }

    @Getter
    @NoArgsConstructor
    static class ErrorResult {
        private final List<FieldValidationError> fieldErrors = new ArrayList<>();

        ErrorResult(String field, String message) {
            this.fieldErrors.add(new FieldValidationError(field, message));
        }
    }

    @Getter
    @AllArgsConstructor
    static class FieldValidationError {
        private String field;
        private String message;
    }
}

如果 bean 验证失败,Spring 将抛出 MethodArgumentNotValidException。我们通过将 Spring 的 FieldError 对象映射到我们自己的 ErrorResult 数据结构来处理这个异常。在这种情况下,异常处理程序会导致所有控制器返回 HTTP 状态 400,并将 ErrorResult 对象作为 JSON 字符串放入响应正文中。
为了验证这确实发生了,我们扩展了我们之前对失败验证的测试:

@Test
void whenNullValue_thenReturns400AndErrorResult() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

  MvcResult mvcResult = mockMvc.perform(...)
          .contentType("application/json")
          .param("sendWelcomeMail", "true")
          .content(objectMapper.writeValueAsString(user)))
          .andExpect(status().isBadRequest())
          .andReturn();

  ErrorResult expectedErrorResponse = new ErrorResult("name", "must not be null");
  String actualResponseBody =
      mvcResult.getResponse().getContentAsString();
  String expectedResponseBody =
      objectMapper.writeValueAsString(expectedErrorResponse);
  assertThat(actualResponseBody)
      .isEqualToIgnoringWhitespace(expectedResponseBody);
}

同样,我们从响应正文中读取 JSON 字符串,并将其与预期的 JSON 字符串进行比较。此外,我们检查响应状态是否为 400。
这也可以以可读性更强的方式实现,我们接下来将要学习。创建自定义 ResultMatcher
某些断言很难写,更重要的是,很难阅读。特别是当我们想要将来自 HTTP 响应的 JSON 字符串与预期值进行比较时,它需要大量代码,正如我们在最后两个示例中看到的那样。
幸运的是,我们可以创建自定义的 ResultMatcher,我们可以在 MockMvc 的流畅 API 中使用它们。让我们看看如何做到这一点。匹配 JSON 输出
使用以下代码来验证 HTTP 响应正文是否包含某个 Java 对象的 JSON 表示不是很好吗?

@Test
void whenValidInput_thenReturnsUserResource_withFluentApi() throws Exception {
  UserResource user = ...;
  UserResource expected = ...;

  mockMvc.perform(...)
      ...
      .andExpect(responseBody().containsObjectAsJson(expected, UserResource.class));
}

不再需要手动比较 JSON 字符串。它的可读性要好得多。事实上,代码是如此的一目了然,这里我无需解释。
为了能够使用上面的代码,我们创建了一个自定义的 ResultMatcher

public class ResponseBodyMatchers {
    private ObjectMapper objectMapper = new ObjectMapper();

    public <T> ResultMatcher containsObjectAsJson(Object expectedObject, Class<T> targetClass) {
        return mvcResult -> {
            String json = mvcResult.getResponse().getContentAsString();
            T actualObject = objectMapper.readValue(json, targetClass);
            assertThat(actualObject).isEqualToComparingFieldByField(expectedObject);
        };
    }

    static ResponseBodyMatchers responseBody() {
        return new ResponseBodyMatchers();
    }

}

静态方法 responseBody() 用作我们流畅的 API 的入口点。它返回实际的 ResultMatcher,它从 HTTP 响应正文解析 JSON,并将其与传入的预期对象逐个字段进行比较。匹配预期的验证错误
我们甚至可以更进一步简化我们的异常处理测试。我们用了 4 行代码来验证 JSON 响应是否包含某个错误消息。我们可以改为一行:

@Test
void whenNullValue_thenReturns400AndErrorResult_withFluentApi() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

  mockMvc.perform(...)
      ...
      .content(objectMapper.writeValueAsString(user)))
      .andExpect(status().isBadRequest())
      .andExpect(responseBody().containsError("name", "must not be null"));
}

同样,代码是自解释的。
为了启用这个流畅的 API,我们必须从上面添加方法 containsErrorMessageForField() 到我们的 ResponseBodyMatchers 类:

public class ResponseBodyMatchers {
    private ObjectMapper objectMapper = new ObjectMapper();

    public ResultMatcher containsError(String expectedFieldName, String expectedMessage) {
        return mvcResult -> {
            String json = mvcResult.getResponse().getContentAsString();
            ErrorResult errorResult = objectMapper.readValue(json, ErrorResult.class);
            List<FieldValidationError> fieldErrors = errorResult.getFieldErrors().stream()
                    .filter(fieldError -> fieldError.getField().equals(expectedFieldName))
                    .filter(fieldError -> fieldError.getMessage().equals(expectedMessage)).collect(Collectors.toList());

            assertThat(fieldErrors).hasSize(1).withFailMessage(
                    "expecting exactly 1 error message" + "with field name '%s' and message '%s'", expectedFieldName,
                    expectedMessage);
        };
    }

    static ResponseBodyMatchers responseBody() {
        return new ResponseBodyMatchers();
    }
}

所有丑陋的代码都隐藏在这个辅助类中,我们可以在集成测试中愉快地编写干净的断言。

结论

Web 控制器有很多职责。如果我们想用有意义的测试覆盖一个 web 控制器,仅仅检查它是否返回正确的 HTTP 状态是不够的。
通过 @WebMvcTest,Spring Boot 提供了我们构建 Web 控制器测试所需的一切,但为了使测试有意义,我们需要记住涵盖所有职责。否则,我们可能会在运行时遇到丑陋的惊喜。
本文中的示例代码可在 GitHub 上找到。

使用 Spring Boot 进行单元测试

【注】本文译自: Unit Testing with Spring Boot – Reflectoring

编写好的单元测试可以被认为是一门难以掌握的艺术。但好消息是支持它的机制很容易学习。
本教程为您提供了这些机制,并详细介绍了编写良好的单元测试所必需的技术细节,重点是 Spring Boot 应用程序。
我们将看看如何以可测试的方式创建 Spring bean,然后讨论 Mockito 和 AssertJ 的用法,这两个库默认包含在 Spring Boot 中用于测试。
请注意,本文仅讨论单元测试。集成测试、Web 层测试和持久层测试将在本系列的后续文章中讨论。

 代码示例

本文附有 GitHub 上 的工作代码示例。

“使用 Spring Boot 进行测试”系列

本教程是系列的一部分:

  1. 使用 Spring Boot 进行单元测试
  2. 使用 Spring Boot 和 @WebMvcTest 测试 MVC Web Controller
  3. 使用 Spring Boot 和 @DataJpaTest 测试 JPA 查询
  4. 使用 Spring Boot 和 @SpringBootTest 进行测试

依赖关系

对于本教程中的单元测试,我们将使用 JUnit Jupiter (JUnit 5)、Mockito 和 AssertJ。我们还将包括 Lombok 以减少一些样板代码:

dependencies {
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.junit.jupiter:junit-jupiter:5.4.0')
    testCompile('org.mockito:mockito-junit-jupiter:2.23.0')
}

Mockito 和 AssertJ 是使用 spring-boot-starter-test 依赖项自动导入的,但我们必须自己包含 Lombok。

不要在单元测试中使用 Spring

如果你以前用 Spring 或 Spring Boot 写过测试,你可能会说我们不需要 Spring 来写单元测试。这是为什么?
考虑以下测试 RegisterUseCase 类的单个方法的“单元”测试:

@ExtendWith(SpringExtension.class)
@SpringBootTest
class RegisterUseCaseTest {

    @Autowired
    private RegisterUseCase registerUseCase;

    @Test
    void savedUserHasRegistrationDate() {
        User user = new User("zaphod", "zaphod@mail.com");
        User savedUser = registerUseCase.registerUser(user);
        assertThat(savedUser.getRegistrationDate()).isNotNull();
    }

}

这个测试在我电脑上的一个空 Spring 项目上运行大约需要 4.5 秒。
但是一个好的单元测试只需要几毫秒。否则它会阻碍由测试驱动开发(TDD)思想推动的“测试/代码/测试”流程。但即使我们不采用 TDD,等待太长时间的测试也会破坏我们的注意力。
执行上面的测试方法实际上只需要几毫秒。 剩下的 4.5 秒是由于 @SpringBootRun 告诉 Spring Boot 设置整个 Spring Boot 应用程序上下文。
所以我们启动了整个应用程序只是为了将 RegisterUseCase 实例自动装配到我们的测试中。一旦应用程序变大并且 Spring 不得不将越来越多的 bean 加载到应用程序上下文中,它将花费更长的时间。
那么,为什么我们不应该在单元测试中使用 Spring Boot 呢?老实说,本教程的大部分内容都是关于在没有 Spring Boot 的情况下编写单元测试。

创建可测试的 Spring Bean

然而,我们可以做一些事情来提高 Spring bean 的可测试性。

字段注入是不可取的

让我们从一个不好的例子开始。考虑以下类:

@Service
public class RegisterUseCase {

    @Autowired
    private UserRepository userRepository;

    public User registerUser(User user) {
        return userRepository.save(user);
    }

}

这个类不能在没有 Spring 的情况下进行单元测试,因为它没有提供传递 UserRepository 实例的方法。那么,我们需要按照上一节中讨论的方式编写测试,让 Spring 创建一个 UserRepository 实例并将其注入到用 @Autowired 注解的字段中。

这里的教训是不要使用字段注入

提供构造函数

实际上,我们根本不要使用 @Autowired 注解:

@Service
public class RegisterUseCase {

    private final UserRepository userRepository;

    public RegisterUseCase(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User registerUser(User user) {
        return userRepository.save(user);
    }

}

这个版本通过提供允许传入 UserRepository 实例的构造函数来允许构造函数注入。在单元测试中,我们现在可以创建这样一个实例(可能是我们稍后讨论的模拟实例)并将其传递给构造函数。
在创建生产应用程序上下文时,Spring 将自动使用此构造函数来实例化 RegisterUseCase 对象。注意,在 Spring 5 之前,我们需要在构造函数中添加 @Autowired 注解,以便 Spring 找到构造函数。
还要注意 UserRepository 字段现在是 final。这是有道理的,因为字段内容在应用程序的生命周期内永远不会改变。它还有助于避免编程错误,因为如果我们忘记初始化字段,编译器会报错。

减少样板代码

使用 Lombok 的 @RequiredArgsConstructor 注解,我们可以让构造函数自动生成:

@Service
@RequiredArgsConstructor
public class RegisterUseCase {

    private final UserRepository userRepository;

    public User registerUser(User user) {
        user.setRegistrationDate(LocalDateTime.now());
        return userRepository.save(user);
    }

}

现在,我们有一个非常简洁的类,没有样板代码,可以在普通的 java 测试用例中轻松实例化:

class RegisterUseCaseTest {

    private UserRepository userRepository = ...;

    private RegisterUseCase registerUseCase;

    @BeforeEach
    void initUseCase() {
        registerUseCase = new RegisterUseCase(userRepository);
    }

    @Test
    void savedUserHasRegistrationDate() {
        User user = new User("zaphod", "zaphod@mail.com");
        User savedUser = registerUseCase.registerUser(user);
        assertThat(savedUser.getRegistrationDate()).isNotNull();
    }

}

然而,还缺少一点,那就是如何模拟我们被测类所依赖的 UserRepository 实例,因为我们不想依赖真实的东西,它可能需要连接到数据库。

使用 Mockito 来模拟依赖

现在事实上的标准模拟库是 Mockito。它至少提供了两种方法来创建模拟的 UserRepository 以填补前面代码示例中的空白。

使用普通 Mockito 模拟依赖项

第一种方法是以编程方式使用 Mockito:

private UserRepository userRepository = Mockito.mock(UserRepository.class);

这将创建一个从外部看起来像 UserRepository 的对象。默认情况下,当一个方法被调用时它什么都不做,如果该方法有返回值则返回 null
我们的测试现在将在 assertThat(savedUser.getRegistrationDate()).isNotNull() 处以 NullPointerException 失败,因为 userRepository.save(user) 现在返回 null
所以,我们必须告诉 Mockito 在调用 userRepository.save() 时返回一些东西。我们使用静态 when 方法来做到这一点:

    @Test
    void savedUserHasRegistrationDate() {
        User user = new User("zaphod", "zaphod@mail.com");
        when(userRepository.save(any(User.class))).then(returnsFirstArg());
        User savedUser = registerUseCase.registerUser(user);
        assertThat(savedUser.getRegistrationDate()).isNotNull();
    }

这将使 userRepository.save() 返回传递给方法的相同用户对象。
Mockito 具有更多功能,可以进行模拟、匹配参数和验证方法调用。有关更多信息,请查看参考文档

使用 Mockito 的 @Mock 注解模拟依赖项

创建模拟对象的另一种方法是 Mockito 的 @Mock 注解与 JUnit Jupiter 的 MockitoExtension 相结合:

@ExtendWith(MockitoExtension.class)
class RegisterUseCaseTest {

    @Mock
    private UserRepository userRepository;

    private RegisterUseCase registerUseCase;

    @BeforeEach
    void initUseCase() {
        registerUseCase = new RegisterUseCase(userRepository);
    }

    @Test
    void savedUserHasRegistrationDate() {
        // ...
    }

}

@Mock 注解指定了 Mockito 应该注入模拟对象的字段。 @MockitoExtension 告诉 Mockito 评估那些 @Mock 注解,因为 JUnit 不会自动执行此操作。
结果和手动调用 Mockito.mock() 一样,选择使用哪种方式是品味问题。 但是请注意,通过使用 MockitoExtension 将我们的测试绑定到测试框架。
请注意,我们也可以在 registerUseCase 字段上使用 @InjectMocks 注解,而不是手动构造 RegisterUseCase 对象。然后 Mockito 会按照指定的算法为我们创建一个实例:

@ExtendWith(MockitoExtension.class)
class RegisterUseCaseTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private RegisterUseCase registerUseCase;

    @Test
    void savedUserHasRegistrationDate() {
        // ...
    }

}

使用 AssertJ 创建可读断言

Spring Boot 测试支持自动附带的另一个库是 AssertJ。我们已经在上面使用它来实现我们的断言:

assertThat(savedUser.getRegistrationDate()).isNotNull();

然而,让断言更具可读性不是更好吗?例如:

assertThat(savedUser).hasRegistrationDate();

在很多情况下,像这样的小改动会使测试更容易理解。因此,让我们在测试源文件夹中创建我们自己的自定义断言

class UserAssert extends AbstractAssert<UserAssert, User> {

    UserAssert(User user) {
        super(user, UserAssert.class);
    }

    static UserAssert assertThat(User actual) {
        return new UserAssert(actual);
    }

    UserAssert hasRegistrationDate() {
        isNotNull();
        if (actual.getRegistrationDate() == null) {
            failWithMessage(
                    "Expected user to have a registration date, but it was null"
            );
        }
        return this;
    }
}

现在,如果我们从新的 UserAssert 类而不是从 AssertJ 库导入 assertThat 方法,我们就可以使用新的、更易于阅读的断言。
创建像这样的自定义断言似乎需要很多工作,但实际上只需几分钟即可完成。我坚信投入这些时间来创建可读的测试代码是值得的,即使之后它的可读性只是稍微好一点。毕竟,我们只编写一次测试代码,其他人(包括“未来的我”)必须在软件的生命周期中多次阅读、理解和操作代码
如果仍然觉得工作量太大,请查看 AssertJ 的断言生成器

结论

在测试中启动 Spring 应用程序是有原因的,但对于普通的单元测试来说,这是没有必要的。由于更长的周转时间,它甚至是有害的。相反,我们应该以一种易于支持为其编写简单单元测试的方式构建我们的 Spring bean。
Spring Boot Test Starter 附带 Mockito 和 AssertJ 作为测试库。
让我们利用这些测试库来创建富有表现力的单元测试!
最终形式的代码示例可在 github 上 找到。