Spring-Boot-启动指南-四-

79 阅读25分钟

Spring Boot 启动指南(四)

原文:zh.annas-archive.org/md5/8803f34bb871785b4bbbecddf52d5733

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:部署您的 Spring Boot 应用程序

在软件开发中,部署是将应用程序推向生产的入口。

无论应用程序向最终用户承诺了多少功能,直到这些用户真正能够使用该应用程序,它都仅仅是一种学术性的假设。从比喻和实际上来看,部署是应用程序的回报。

参考 Spring Initializr,许多开发人员知道 Spring Boot 应用程序可以创建为 WAR 文件或 JAR 文件。大多数开发人员也知道有很多很好的理由(本书前面提到的几个)不选择 WAR 选项,而选择创建可执行的 JAR 文件,反之则没有几个好理由。许多开发人员可能没有意识到的是,即使构建 Spring Boot 可执行 JAR,也有许多部署选项可以满足各种需求和用例。

在本章中,我将探讨部署 Spring Boot 应用程序的不同目标位置的有用选项,并讨论它们的相对优点。然后,我将演示如何创建这些部署工件,解释实现最佳执行的选项,并展示如何验证它们的组件和来源。您几乎可以肯定,您有比您意识到的更多和更好的工具来部署您的 Spring Boot 应用程序。

代码检出检查

请从代码库中检查分支 chapter11begin 开始。

重新审视 Spring Boot 可执行 JAR

正如在第一章中讨论的那样,Spring Boot 的可执行 JAR 提供了单一、自包含、可测试和可部署单元的最大效用和多样性。创建和迭代速度快,动态自配置以适应环境变化,并且非常简单地分发和维护。

每个云服务提供商都提供了一个应用程序托管选项,广泛用于从原型到生产部署,大多数这些应用平台都期望一个基本上是自包含的可部署应用程序,只提供最基本的环境要求。Spring Boot JAR 在这些干净的环境中非常自然地适应,只需有 JDK 存在即可无摩擦地执行;一些平台甚至因其与应用托管的完美匹配而具体指定使用 Spring Boot。通过带有 HTTP 交换、消息传递等外部交互机制,Spring Boot 应用程序可以消除应用服务器或其他外部依赖的安装、配置和维护。这极大地减少了开发工作量和应用平台的开销。

由于 Spring Boot 应用程序完全控制依赖库,因此它消除了对外部依赖变更的恐惧。多年来,对于依赖于底层应用平台维护的外部组件的应用程序,计划更新应用服务器、servlet 引擎、数据库或消息传递库等诸多关键组件时,导致了无数非 Boot 应用程序的崩溃。在这些应用程序中,开发人员必须高度警惕,以防因单个依赖库的点发布变更而导致不计其数的未计划停机。激动人心的时刻。

对于 Spring Boot 应用程序,无论是核心 Spring 库还是第二(或第三、第四等)层依赖关系的升级,都不再那么痛苦和紧张。应用程序开发人员升级并测试应用程序,并在满意一切正常时部署更新(通常使用 blue-green 部署)。由于依赖项不再是应用程序外部的,而是与之捆绑在一起,开发人员可以完全控制依赖项版本和升级时机。

Spring Boot JAR 还有一个有用的技巧,感谢 Spring Boot Maven 和 Gradle 插件:能够创建所谓的“完全可执行” JAR。引号是有意的,并且也在官方文档中出现,因为应用程序仍然需要 JDK 才能正常运行。那么,“完全可执行”的 Spring Boot 应用程序是什么意思,如何创建它呢?

让我们从“如何”开始。

创建“完全可执行”的 Spring Boot JAR

我将使用 PlaneFinder 作为示例。为了比较,我使用 mvn clean package 命令在不进行任何更改的情况下从命令行构建项目。这导致在项目的 target 目录中创建了以下 JAR 文件(结果进行了修整以适应页面):

» ls -lb target/*.jar

-rw-r--r--  1 markheckler  staff  27085204 target/planefinder-0.0.1-SNAPSHOT.jar

这个 Spring Boot JAR 被称为“可执行 JAR”,因为它包含了整个应用程序,无需外部依赖;要执行它,只需安装 JDK 并提供 JVM。以当前状态运行该应用程序看起来像这样(结果进行了修整以适应页面):

» java -jar target/planefinder-0.0.1-SNAPSHOT.jar

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.0)

: Starting PlanefinderApplication v0.0.1-SNAPSHOT
: No active profile set, falling back to default profiles: default
: Bootstrapping Spring Data R2DBC repositories in DEFAULT mode.
: Finished Spring Data repository scanning in 132 ms. Found 1 R2DBC
  repository interfaces.
: Netty started on port(s): 7634
: Netty RSocket started on port(s): 7635
: Started PlanefinderApplication in 2.75 seconds (JVM running for 3.106)

当然,这符合预期,并且它作为接下来的基准。现在我重新访问 PlaneFinder 的 pom.xml,以在现有的 spring-boot-maven-plug-in 部分中添加所示的 XML 片段,如 Figure 11-1 中所示。

sbur 1101

Figure 11-1. PlaneFinder pom.xml 文件的插件部分

回到终端后,我再次使用 mvn clean package 命令从命令行构建项目。这次,在项目的 target 目录中创建的 JAR 文件有明显的不同,如下输出所示(结果进行了修整以适应页面):

» ls -lb target/*.jar

-rwxr--r--  1 markheckler  staff  27094314 target/planefinder-0.0.1-SNAPSHOT.jar

它比 Boot 的标准可执行 JAR 稍大一点,大约是 9,110 字节,或者稍少于 9 KB。这带来了什么好处呢?

Java JAR 文件是从结尾向开头读取的——是的,您没有看错——直到找到文件结束标记。当创建所谓的“完全可执行 JAR”时,Spring Boot Maven 插件巧妙地在通常的 Spring Boot 可执行 JAR 的开头添加了一个脚本,使其能够在类 Unix 或 Linux 系统上像任何其他可执行二进制文件一样运行(假设存在 JDK),包括在init.dsystemd中注册。在编辑器中检查 PlaneFinder 的 JAR 文件结果如下(为简洁起见,仅显示了脚本头的部分内容;它非常广泛):

#!/bin/bash
#
#    .   ____          _            __ _ _
#   /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
#  ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
#   \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
#    '  |____| .__|_| |_|_| |_\__, | / / / /
#   =========|_|==============|___/=/_/_/_/
#   :: Spring Boot Startup Script ::
#

### BEGIN INIT INFO
# Provides:          planefinder
# Required-Start:    $remote_fs $syslog $network
# Required-Stop:     $remote_fs $syslog $network
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: planefinder
# Description:       Data feed for SBUR
# chkconfig:         2345 99 01
### END INIT INFO

...

# Action functions
start() {
  if [[ -f "$pid_file" ]]; then
    pid=$(cat "$pid_file")
    isRunning "$pid" && { echoYellow "Already running [$pid]"; return 0; }
  fi
  do_start "$@"
}

do_start() {
  working_dir=$(dirname "$jarfile")
  pushd "$working_dir" > /dev/null
  if [[ ! -e "$PID_FOLDER" ]]; then
    mkdir -p "$PID_FOLDER" &> /dev/null
    if [[ -n "$run_user" ]]; then
      chown "$run_user" "$PID_FOLDER"
    fi
  fi
  if [[ ! -e "$log_file" ]]; then
    touch "$log_file" &> /dev/null
    if [[ -n "$run_user" ]]; then
      chown "$run_user" "$log_file"
    fi
  fi
  if [[ -n "$run_user" ]]; then
    checkPermissions || return $?
    if [ $USE_START_STOP_DAEMON = true ] && type start-stop-daemon >
        /dev/null 2>&1; then
      start-stop-daemon --start --quiet \
        --chuid "$run_user" \
        --name "$identity" \
        --make-pidfile --pidfile "$pid_file" \
        --background --no-close \
        --startas "$javaexe" \
        --chdir "$working_dir" \
       —"${arguments[@]}" \
        >> "$log_file" 2>&1
      await_file "$pid_file"
    else
      su -s /bin/sh -c "$javaexe $(printf "\"%s\" " "${arguments[@]}") >>
        \"$log_file\" 2>&1 & echo \$!" "$run_user" > "$pid_file"
    fi
    pid=$(cat "$pid_file")
  else
    checkPermissions || return $?
    "$javaexe" "${arguments[@]}" >> "$log_file" 2>&1 &
    pid=$!
    disown $pid
    echo "$pid" > "$pid_file"
  fi
  [[ -z $pid ]] && { echoRed "Failed to start"; return 1; }
  echoGreen "Started [$pid]"
}

stop() {
  working_dir=$(dirname "$jarfile")
  pushd "$working_dir" > /dev/null
  [[ -f $pid_file ]] ||
    { echoYellow "Not running (pidfile not found)"; return 0; }
  pid=$(cat "$pid_file")
  isRunning "$pid" || { echoYellow "Not running (process ${pid}).
    Removing stale pid file."; rm -f "$pid_file"; return 0; }
  do_stop "$pid" "$pid_file"
}

do_stop() {
  kill "$1" &> /dev/null || { echoRed "Unable to kill process $1"; return 1; }
  for ((i = 1; i <= STOP_WAIT_TIME; i++)); do
    isRunning "$1" || { echoGreen "Stopped [$1]"; rm -f "$2"; return 0; }
    [[ $i -eq STOP_WAIT_TIME/2 ]] && kill "$1" &> /dev/null
    sleep 1
  done
  echoRed "Unable to kill process $1";
  return 1;
}

force_stop() {
  [[ -f $pid_file ]] ||
    { echoYellow "Not running (pidfile not found)"; return 0; }
  pid=$(cat "$pid_file")
  isRunning "$pid" ||
    { echoYellow "Not running (process ${pid}). Removing stale pid file.";
    rm -f "$pid_file"; return 0; }
  do_force_stop "$pid" "$pid_file"
}

do_force_stop() {
  kill -9 "$1" &> /dev/null ||
      { echoRed "Unable to kill process $1"; return 1; }
  for ((i = 1; i <= STOP_WAIT_TIME; i++)); do
    isRunning "$1" || { echoGreen "Stopped [$1]"; rm -f "$2"; return 0; }
    [[ $i -eq STOP_WAIT_TIME/2 ]] && kill -9 "$1" &> /dev/null
    sleep 1
  done
  echoRed "Unable to kill process $1";
  return 1;
}

restart() {
  stop && start
}

force_reload() {
  working_dir=$(dirname "$jarfile")
  pushd "$working_dir" > /dev/null
  [[ -f $pid_file ]] || { echoRed "Not running (pidfile not found)";
      return 7; }
  pid=$(cat "$pid_file")
  rm -f "$pid_file"
  isRunning "$pid" || { echoRed "Not running (process ${pid} not found)";
      return 7; }
  do_stop "$pid" "$pid_file"
  do_start
}

status() {
  working_dir=$(dirname "$jarfile")
  pushd "$working_dir" > /dev/null
  [[ -f "$pid_file" ]] || { echoRed "Not running"; return 3; }
  pid=$(cat "$pid_file")
  isRunning "$pid" || { echoRed "Not running (process ${pid} not found)";
      return 1; }
  echoGreen "Running [$pid]"
  return 0
}

run() {
  pushd "$(dirname "$jarfile")" > /dev/null
  "$javaexe" "${arguments[@]}"
  result=$?
  popd > /dev/null
  return "$result"
}

# Call the appropriate action function
case "$action" in
start)
  start "$@"; exit $?;;
stop)
  stop "$@"; exit $?;;
force-stop)
  force_stop "$@"; exit $?;;
restart)
  restart "$@"; exit $?;;
force-reload)
  force_reload "$@"; exit $?;;
status)
  status "$@"; exit $?;;
run)
  run "$@"; exit $?;;
*)
  echo "Usage: $0 {start|stop|force-stop|restart|force-reload|status|run}";
    exit 1;
esac

exit 0
<binary portion omitted>

Spring Boot Maven(或选择作为构建系统的 Gradle)插件还会为输出 JAR 设置文件所有者权限以读取、写入和执行(rwx)。这样做使其能够按前述方式执行,并允许头脚本定位 JDK,准备应用程序以及运行它,如此演示(结果已经修整和编辑以适应页面):

» target/planefinder-0.0.1-SNAPSHOT.jar

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.0)

: Starting PlanefinderApplication v0.0.1-SNAPSHOT
: No active profile set, falling back to default profiles: default
: Bootstrapping Spring Data R2DBC repositories in DEFAULT mode.
: Finished Spring Data repository scanning in 185 ms.
  Found 1 R2DBC repository interfaces.
: Netty started on port(s): 7634
: Netty RSocket started on port(s): 7635
: Started PlanefinderApplication in 2.938 seconds (JVM running for 3.335)

现在我已经演示了如何操作,是时候讨论此选项为我们带来了什么。

这是什么意思?

创建 Spring Boot“完全可执行”JAR 的能力并不是解决所有问题的方法,但在必要时它确实提供了与底层 Unix 和 Linux 系统更深层次集成的独特能力。由于嵌入的启动脚本和执行权限,添加 Spring Boot 应用程序以提供启动功能变得非常简单。

如果您的当前应用环境中不需要或无法利用该功能,您应继续简单地创建典型的 Spring Boot 可执行 JAR 输出,利用java -jar。这只是您工具箱中的另一个工具,无需额外成本并且几乎不需要您投入精力去实施,当您发现需要时即可使用。

解压缩的 JAR

Spring Boot 创新的方法将依赖的 JAR 文件完整保留在 Boot 可执行 JAR 文件中,未经更改,非常适合后续操作,如提取。反转添加到 Spring Boot 可执行 JAR 文件中的过程会产生组件工件的原始、未更改状态。听起来很简单,因为确实如此

有很多原因使您希望将 Spring Boot 可执行 JAR 文件重新解压为其各个独立部分:

  • 提取的 Boot 应用程序执行速度略有提升。尽管这很少是重新解压的理由,但这是一个不错的附加优势。

  • 提取的依赖是可以轻松替换的独立单元。应用程序更新可以更快速地进行,或者带宽更低,因为只需重新部署更改的文件。

  • 许多云平台,如 Heroku 和任何构建或基于 Cloud Foundry 的品牌/衍生品,都会在应用部署过程中执行此操作。尽可能地将本地和远程环境镜像化可以帮助确保一致性,并在必要时诊断任何问题。

标准的 Spring Boot 可执行 JAR 和“完全可执行”JAR 都可以通过以下方式重新生成,使用jar -xvf <spring_boot_jar>(为简洁起见,大多数文件条目已删除):

» mkdir expanded
» cd expanded
» jar -xvf ../target/planefinder-0.0.1-SNAPSHOT.jar
  created: META-INF/
 inflated: META-INF/MANIFEST.MF
  created: org/
  created: org/springframework/
  created: org/springframework/boot/
  created: org/springframework/boot/loader/
  created: org/springframework/boot/loader/archive/
  created: org/springframework/boot/loader/data/
  created: org/springframework/boot/loader/jar/
  created: org/springframework/boot/loader/jarmode/
  created: org/springframework/boot/loader/util/
  created: BOOT-INF/
  created: BOOT-INF/classes/
  created: BOOT-INF/classes/com/
  created: BOOT-INF/classes/com/thehecklers/
  created: BOOT-INF/classes/com/thehecklers/planefinder/
  created: META-INF/maven/
  created: META-INF/maven/com.thehecklers/
  created: META-INF/maven/com.thehecklers/planefinder/
 inflated: BOOT-INF/classes/schema.sql
 inflated: BOOT-INF/classes/application.properties
 inflated: META-INF/maven/com.thehecklers/planefinder/pom.xml
 inflated: META-INF/maven/com.thehecklers/planefinder/pom.properties
  created: BOOT-INF/lib/
 inflated: BOOT-INF/classpath.idx
 inflated: BOOT-INF/layers.idx
»

一旦文件被解压缩,我发现使用*nix tree命令更加直观地查看结构是很有用的:

» tree
.
├── BOOT-INF
│   ├── classes
│   │   ├── application.properties
│   │   ├── com
│   │   │   └── thehecklers
│   │   │       └── planefinder
│   │   │           ├── Aircraft.class
│   │   │           ├── DbConxInit.class
│   │   │           ├── PlaneController.class
│   │   │           ├── PlaneFinderService.class
│   │   │           ├── PlaneRepository.class
│   │   │           ├── PlanefinderApplication.class
│   │   │           └── User.class
│   │   └── schema.sql
│   ├── classpath.idx
│   ├── layers.idx
│   └── lib
│       ├── h2-1.4.200.jar
│       ├── jackson-annotations-2.11.3.jar
│       ├── jackson-core-2.11.3.jar
│       ├── jackson-databind-2.11.3.jar
│       ├── jackson-dataformat-cbor-2.11.3.jar
│       ├── jackson-datatype-jdk8-2.11.3.jar
│       ├── jackson-datatype-jsr310-2.11.3.jar
│       ├── jackson-module-parameter-names-2.11.3.jar
│       ├── jakarta.annotation-api-1.3.5.jar
│       ├── jul-to-slf4j-1.7.30.jar
│       ├── log4j-api-2.13.3.jar
│       ├── log4j-to-slf4j-2.13.3.jar
│       ├── logback-classic-1.2.3.jar
│       ├── logback-core-1.2.3.jar
│       ├── lombok-1.18.16.jar
│       ├── netty-buffer-4.1.54.Final.jar
│       ├── netty-codec-4.1.54.Final.jar
│       ├── netty-codec-dns-4.1.54.Final.jar
│       ├── netty-codec-http-4.1.54.Final.jar
│       ├── netty-codec-http2-4.1.54.Final.jar
│       ├── netty-codec-socks-4.1.54.Final.jar
│       ├── netty-common-4.1.54.Final.jar
│       ├── netty-handler-4.1.54.Final.jar
│       ├── netty-handler-proxy-4.1.54.Final.jar
│       ├── netty-resolver-4.1.54.Final.jar
│       ├── netty-resolver-dns-4.1.54.Final.jar
│       ├── netty-transport-4.1.54.Final.jar
│       ├── netty-transport-native-epoll-4.1.54.Final-linux-x86_64.jar
│       ├── netty-transport-native-unix-common-4.1.54.Final.jar
│       ├── r2dbc-h2-0.8.4.RELEASE.jar
│       ├── r2dbc-pool-0.8.5.RELEASE.jar
│       ├── r2dbc-spi-0.8.3.RELEASE.jar
│       ├── reactive-streams-1.0.3.jar
│       ├── reactor-core-3.4.0.jar
│       ├── reactor-netty-core-1.0.1.jar
│       ├── reactor-netty-http-1.0.1.jar
│       ├── reactor-pool-0.2.0.jar
│       ├── rsocket-core-1.1.0.jar
│       ├── rsocket-transport-netty-1.1.0.jar
│       ├── slf4j-api-1.7.30.jar
│       ├── snakeyaml-1.27.jar
│       ├── spring-aop-5.3.1.jar
│       ├── spring-beans-5.3.1.jar
│       ├── spring-boot-2.4.0.jar
│       ├── spring-boot-autoconfigure-2.4.0.jar
│       ├── spring-boot-jarmode-layertools-2.4.0.jar
│       ├── spring-context-5.3.1.jar
│       ├── spring-core-5.3.1.jar
│       ├── spring-data-commons-2.4.1.jar
│       ├── spring-data-r2dbc-1.2.1.jar
│       ├── spring-data-relational-2.1.1.jar
│       ├── spring-expression-5.3.1.jar
│       ├── spring-jcl-5.3.1.jar
│       ├── spring-messaging-5.3.1.jar
│       ├── spring-r2dbc-5.3.1.jar
│       ├── spring-tx-5.3.1.jar
│       ├── spring-web-5.3.1.jar
│       └── spring-webflux-5.3.1.jar
├── META-INF
│   ├── MANIFEST.MF
│   └── maven
│       └── com.thehecklers
│           └── planefinder
│               ├── pom.properties
│               └── pom.xml
└── org
    └── springframework
        └── boot
            └── loader
                ├── ClassPathIndexFile.class
                ├── ExecutableArchiveLauncher.class
                ├── JarLauncher.class
                ├── LaunchedURLClassLoader$DefinePackageCallType.class
                ├── LaunchedURLClassLoader
                    $UseFastConnectionExceptionsEnumeration.class
                ├── LaunchedURLClassLoader.class
                ├── Launcher.class
                ├── MainMethodRunner.class
                ├── PropertiesLauncher$1.class
                ├── PropertiesLauncher$ArchiveEntryFilter.class
                ├── PropertiesLauncher$ClassPathArchives.class
                ├── PropertiesLauncher$PrefixMatchingArchiveFilter.class
                ├── PropertiesLauncher.class
                ├── WarLauncher.class
                ├── archive
                │   ├── Archive$Entry.class
                │   ├── Archive$EntryFilter.class
                │   ├── Archive.class
                │   ├── ExplodedArchive$AbstractIterator.class
                │   ├── ExplodedArchive$ArchiveIterator.class
                │   ├── ExplodedArchive$EntryIterator.class
                │   ├── ExplodedArchive$FileEntry.class
                │   ├── ExplodedArchive$SimpleJarFileArchive.class
                │   ├── ExplodedArchive.class
                │   ├── JarFileArchive$AbstractIterator.class
                │   ├── JarFileArchive$EntryIterator.class
                │   ├── JarFileArchive$JarFileEntry.class
                │   ├── JarFileArchive$NestedArchiveIterator.class
                │   └── JarFileArchive.class
                ├── data
                │   ├── RandomAccessData.class
                │   ├── RandomAccessDataFile$1.class
                │   ├── RandomAccessDataFile$DataInputStream.class
                │   ├── RandomAccessDataFile$FileAccess.class
                │   └── RandomAccessDataFile.class
                ├── jar
                │   ├── AbstractJarFile$JarFileType.class
                │   ├── AbstractJarFile.class
                │   ├── AsciiBytes.class
                │   ├── Bytes.class
                │   ├── CentralDirectoryEndRecord$1.class
                │   ├── CentralDirectoryEndRecord$Zip64End.class
                │   ├── CentralDirectoryEndRecord$Zip64Locator.class
                │   ├── CentralDirectoryEndRecord.class
                │   ├── CentralDirectoryFileHeader.class
                │   ├── CentralDirectoryParser.class
                │   ├── CentralDirectoryVisitor.class
                │   ├── FileHeader.class
                │   ├── Handler.class
                │   ├── JarEntry.class
                │   ├── JarEntryCertification.class
                │   ├── JarEntryFilter.class
                │   ├── JarFile$1.class
                │   ├── JarFile$JarEntryEnumeration.class
                │   ├── JarFile.class
                │   ├── JarFileEntries$1.class
                │   ├── JarFileEntries$EntryIterator.class
                │   ├── JarFileEntries.class
                │   ├── JarFileWrapper.class
                │   ├── JarURLConnection$1.class
                │   ├── JarURLConnection$JarEntryName.class
                │   ├── JarURLConnection.class
                │   ├── StringSequence.class
                │   └── ZipInflaterInputStream.class
                ├── jarmode
                │   ├── JarMode.class
                │   ├── JarModeLauncher.class
                │   └── TestJarMode.class
                └── util
                    └── SystemPropertyUtils.class

19 directories, 137 files
»

使用tree查看 JAR 内容提供了应用程序构成的良好分层显示。它还显示出多个依赖项的组合,为该应用程序提供所选择的能力。在BOOT-INF/lib下列出的文件确认了组件库在构建 Spring Boot JAR 并提取其内容后保持不变,甚至可以到达原始组件 JAR 的时间戳,如下所示(为简洁起见,大多数条目已删除):

» ls -l BOOT-INF/lib
total 52880
-rw-r--r--  1 markheckler  staff  2303679 Oct 14  2019 h2-1.4.200.jar
-rw-r--r--  1 markheckler  staff    68215 Oct  1 22:20 jackson-annotations-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff   351495 Oct  1 22:25 jackson-core-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff  1421699 Oct  1 22:38 jackson-databind-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff    58679 Oct  2 00:17 jackson-dataformat-cbor-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff    34335 Oct  2 00:25 jackson-datatype-jdk8-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff   111008 Oct  2 00:25 jackson-datatype-jsr310-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff     9267 Oct  2 00:25 jackson-module-parameter-
 names-2.11.3.jar
 ...
-rw-r--r--  1 markheckler  staff   374303 Nov 10 09:01 spring-aop-5.3.1.jar
-rw-r--r--  1 markheckler  staff   695851 Nov 10 09:01 spring-beans-5.3.1.jar
-rw-r--r--  1 markheckler  staff  1299025 Nov 12 13:56 spring-boot-2.4.0.jar
-rw-r--r--  1 markheckler  staff  1537971 Nov 12 13:55 spring-boot-
 autoconfigure-2.4.0.jar
-rw-r--r--  1 markheckler  staff    32912 Feb  1  1980 spring-boot-jarmode-
 layertools-2.4.0.jar
-rw-r--r--  1 markheckler  staff  1241939 Nov 10 09:01 spring-context-5.3.1.jar
-rw-r--r--  1 markheckler  staff  1464734 Feb  1  1980 spring-core-5.3.1.jar
-rw-r--r--  1 markheckler  staff  1238966 Nov 11 12:03 spring-data-commons-
 2.4.1.jar
-rw-r--r--  1 markheckler  staff   433079 Nov 11 12:08 spring-data-r2dbc-
 1.2.1.jar
-rw-r--r--  1 markheckler  staff   339745 Nov 11 12:05 spring-data-relational-
 2.1.1.jar
-rw-r--r--  1 markheckler  staff   282565 Nov 10 09:01 spring-expression-
 5.3.1.jar
-rw-r--r--  1 markheckler  staff    23943 Nov 10 09:01 spring-jcl-5.3.1.jar
-rw-r--r--  1 markheckler  staff   552895 Nov 10 09:01 spring-messaging-
 5.3.1.jar
-rw-r--r--  1 markheckler  staff   133156 Nov 10 09:01 spring-r2dbc-5.3.1.jar
-rw-r--r--  1 markheckler  staff   327956 Nov 10 09:01 spring-tx-5.3.1.jar
-rw-r--r--  1 markheckler  staff  1546053 Nov 10 09:01 spring-web-5.3.1.jar
-rw-r--r--  1 markheckler  staff   901591 Nov 10 09:01 spring-webflux-5.3.1.jar
»

从 Spring Boot JAR 中提取所有文件后,有几种方法可以运行应用程序。推荐的方法是使用JarLauncher,它在执行过程中保持一致的类加载顺序,如下所示(结果已修剪和编辑以适合页面):

» java org.springframework.boot.loader.JarLauncher

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.0)

: Starting PlanefinderApplication v0.0.1-SNAPSHOT
: No active profile set, falling back to default profiles: default
: Bootstrapping Spring Data R2DBC repositories in DEFAULT mode.
: Finished Spring Data repository scanning in 95 ms. Found 1 R2DBC
  repository interfaces.
: Netty started on port(s): 7634
: Netty RSocket started on port(s): 7635
: Started PlanefinderApplication in 1.935 seconds (JVM running for 2.213)

在这种情况下,PlaneFinder 的启动速度比 Spring Boot“完全可执行”JAR 中展开的速度快了整整一秒以上。单独这一点可能或可能不足以抵消单一、完全自包含的可部署单元的优势;但结合仅在少量文件更改时推送增量以及(如适用)本地和远程环境更好的对齐能力,运行展开的 Spring Boot 应用程序的能力可能是一个非常有用的选择。

将 Spring Boot 应用程序部署到容器中

正如前面提到的,一些云平台——包括本地/私有和公共云——接收可部署的应用程序,并代表开发者创建一个容器映像,使用应用程序开发者提供的广泛优化的默认设置。然后根据应用程序的复制设置和利用率使用这些映像创建(和销毁)带有运行中应用程序的容器。像 Heroku 和众多 Cloud Foundry 版本一样,允许开发者推送 Spring Boot 可执行 JAR,并提供任何所需的配置设置(或简单接受默认设置),其余由平台处理。其他平台如 VMware 的 Tanzu Application Service for Kubernetes 也包括此功能,并且功能列表在范围和流动执行方面都在增加。

许多平台和部署目标不支持这种无摩擦的开发者启用级别。无论您或您的组织是否致力于其他提供方之一,还是如果您有其他要求指导您朝不同方向发展,Spring Boot 都为您提供了保障。

尽管您可以为 Spring Boot 应用程序手工制作自己的容器映像,但这并不是最佳选择;这样做对应用程序本身没有任何价值,通常被认为是从开发到生产的一种必要之恶(充其量)。不再如此。

利用之前提到的平台使用的许多相同工具,智能地容器化应用程序,Spring Boot 在其 Maven 和 Gradle 插件中集成了能够无痛无摩擦地构建符合 Open Container Initiative (OCI) 标准的映像的能力,这些映像可供 Docker、Kubernetes 和每个主要的容器引擎/机制使用。基于业界领先的 Cloud Native BuildpacksPaketo 构建包倡议,Spring Boot 构建插件提供了使用本地安装和本地运行的 Docker 守护程序创建 OCI 映像并将其推送到本地或指定的远程映像存储库的选项。

使用 Spring Boot 插件从您的应用程序创建映像也是基于一种最佳实践,利用概念上的“自动配置”来通过分层图像内容优化图像创建,根据每个代码单元预期的变更频率分离代码/库。遵循 Spring Boot 自动配置和最佳实践背后的理念,Boot 还提供了一种覆盖和指导分层过程的方式,如果需要自定义配置,则很少需要或甚至不可取,但如果您的需求属于这些罕见的、特殊的情况之一,则可以轻松实现。

从 Spring Boot 2.4.0 Milestone 2 版本开始,默认设置为所有版本生成以下层:

dependencies

包括定期发布的依赖项,即 GA 版本

spring-boot-loader

包括在 org/springframework/boot/loader 下找到的所有文件

snapshot-dependencies

尚未被视为 GA 的前瞻性发布

application

应用程序类及相关资源(模板、属性文件、脚本等)

代码的易变性或其变更倾向和频率通常随着从上到下浏览此层列表而增加。通过创建单独的层来放置类似易变的代码,随后的映像创建效率更高,因此完成速度更快。这显著减少了在应用程序生命周期内重建可部署构件所需的时间和资源。

从 IDE 创建容器映像

使用 IntelliJ 作为示例,从 Spring Boot 应用程序创建分层容器映像非常简单,但几乎所有主要的 IDE 都具有类似的功能。

注意

必须在本地运行 Docker 版本——在我的情况下是 Docker Desktop for Mac ——才能创建图像。

要创建该图像,我通过展开 IntelliJ 右边缘标签为Maven的选项,然后展开Plugins,选择并展开spring-boot插件,双击spring-boot:build-image选项以执行目标,如图 11-2 所示。

sbur 1102

图 11-2. 从 IntelliJ 的 Maven 面板构建 Spring Boot 应用程序容器镜像

创建图像会生成一份相当冗长的操作日志。特别值得关注的是以下条目:

[INFO]     [creator]     Paketo Executable JAR Buildpack 3.1.3
[INFO]     [creator]       https://github.com/paketo-buildpacks/executable-jar
[INFO]     [creator]         Writing env.launch/CLASSPATH.delim
[INFO]     [creator]         Writing env.launch/CLASSPATH.prepend
[INFO]     [creator]       Process types:
[INFO]     [creator]         executable-jar: java org.springframework.boot.
    loader.JarLauncher
[INFO]     [creator]         task:           java org.springframework.boot.
    loader.JarLauncher
[INFO]     [creator]         web:            java org.springframework.boot.
    loader.JarLauncher
[INFO]     [creator]
[INFO]     [creator]     Paketo Spring Boot Buildpack 3.5.0
[INFO]     [creator]       https://github.com/paketo-buildpacks/spring-boot
[INFO]     [creator]       Creating slices from layers index
[INFO]     [creator]         dependencies
[INFO]     [creator]         spring-boot-loader
[INFO]     [creator]         snapshot-dependencies
[INFO]     [creator]         application
[INFO]     [creator]       Launch Helper: Contributing to layer
[INFO]     [creator]         Creating /layers/paketo-buildpacks_spring-boot/
    helper/exec.d/spring-cloud-bindings
[INFO]     [creator]         Writing profile.d/helper
[INFO]     [creator]       Web Application Type: Contributing to layer
[INFO]     [creator]         Reactive web application detected
[INFO]     [creator]         Writing env.launch/BPL_JVM_THREAD_COUNT.default
[INFO]     [creator]       Spring Cloud Bindings 1.7.0: Contributing to layer
[INFO]     [creator]         Downloading from
    https://repo.spring.io/release/org/springframework/cloud/
    spring-cloud-bindings/1.7.0/spring-cloud-bindings-1.7.0.jar
[INFO]     [creator]         Verifying checksum
[INFO]     [creator]         Copying to
    /layers/paketo-buildpacks_spring-boot/spring-cloud-bindings
[INFO]     [creator]       4 application slices

正如前文所述,图像层(在前述列表中称为slices)及其内容可根据需要进行修改以应对特定情况。

一旦图像创建完成,类似以下所示的结果将完成日志。

[INFO] Successfully built image 'docker.io/library/aircraft-positions:
       0.0.1-SNAPSHOT'
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  25.851 s
[INFO] Finished at: 2020-11-28T20:09:48-06:00
[INFO] ------------------------------------------------------------------------

从命令行创建容器镜像

当然,也可以——而且很简单——从命令行创建相同的容器镜像。在此之前,我确实希望对生成的镜像的命名设置进行一些小改动。

为了方便起见,我更喜欢创建与我的Docker Hub帐户和命名约定相符的图像,你选择的图像仓库可能有类似的特定约定。Spring Boot 的构建插件接受部分的详细信息,以简化将图像推送到仓库/目录的步骤。我向Aircraft Positionpom.xml文件的**部分添加了一行正确标记的代码,以匹配我的需求/偏好:

<build>
  <plug-ins>
    <plug-in>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plug-in</artifactId>
      <configuration>
        <image>
          <name>hecklerm/${project.artifactId}</name>
        </image>
      </configuration>
    </plug-in>
  </plug-ins>
</build>

接下来,我从项目目录中的终端窗口发出以下命令以重新创建应用程序容器镜像,并很快收到如下结果:

» mvn spring-boot:build-image

... (Intermediate logged results omitted for brevity)

[INFO] Successfully built image 'docker.io/hecklerm/aircraft-positions:latest'
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  13.257 s
[INFO] Finished at: 2020-11-28T20:23:40-06:00
[INFO] ------------------------------------------------------------------------

注意,输出的图像不再是在 IDE 中使用默认设置构建时的docker.io/library/aircraft-positions:0.0.1-SNAPSHOT。新的图像坐标与我在pom.xml中指定的相匹配:docker.io/hecklerm/aircraft-positions:latest

验证图像存在

为验证前两节中创建的图像是否已加载到本地仓库,我从终端窗口运行以下命令,并按名称进行过滤以获得如下结果(并已剪裁以适应页面):

» docker images | grep -in aircraft-positions
aircraft-positions           0.0.1-SNAPSHOT   a7ed39a3d52e    277MB
hecklerm/aircraft-positions  latest           924893a0f1a9    277MB

推送上述输出中最后显示的图像——因为它现在与预期和期望的帐户及命名约定一致——到 Docker Hub 的步骤如下,并获得以下结果:

» docker push hecklerm/aircraft-positions
The push refers to repository [docker.io/hecklerm/aircraft-positions]
1dc94a70dbaa: Pushed
4672559507f8: Pushed
e3e9839150af: Pushed
5f70bf18a086: Layer already exists
a3abfb734aa5: Pushed
3c14fe2f1177: Pushed
4cc7b4eb8637: Pushed
fcc507beb4cc: Pushed
c2e9ddddd4ef: Pushed
108b6855c4a6: Pushed
ab39aa8fd003: Layer already exists
0b18b1f120f4: Layer already exists
cf6b3a71f979: Pushed
ec0381c8f321: Layer already exists
7b0fc1578394: Pushed
eb0f7cd0acf8: Pushed
1e5c1d306847: Mounted from paketobuildpacks/run
23c4345364c2: Mounted from paketobuildpacks/run
a1efa53a237c: Mounted from paketobuildpacks/run
fe6d8881187d: Mounted from paketobuildpacks/run
23135df75b44: Mounted from paketobuildpacks/run
b43408d5f11b: Mounted from paketobuildpacks/run
latest: digest:
  sha256:a7e5d536a7426d6244401787b153ebf43277fbadc9f43a789f6c4f0aff6d5011
    size: 5122
»

访问 Docker Hub 可以确认图像已成功公开部署,如图 11-3 所示。

sbur 1103

图 11-3. Docker Hub 中的 Spring Boot 应用程序容器镜像

在将 Spring Boot 容器化应用程序部署到 Docker Hub 或任何其他可以从本地计算机外部访问的容器映像存储库之前,是更广泛(并希望是生产)部署的最后一步。

运行容器化应用程序

要运行该应用程序,我使用docker run命令。您的组织可能有一个部署流水线,从容器镜像(从镜像存储库检索)中移动应用程序到运行的容器化应用程序,但执行的步骤可能是相同的,尽管有更多的自动化和较少的输入。

由于我已经有了映像的本地副本,因此不需要进行远程检索;否则,需要通过守护程序从映像存储库检索远程映像和/或层,在基于指定映像启动容器之前在本地重构它。

要运行容器化的 Aircraft Positions 应用程序,我执行以下命令并看到以下结果(修剪和编辑以适应页面):

» docker run --name myaircraftpositions -p8080:8080
  hecklerm/aircraft-positions:latest
Setting Active Processor Count to 6
WARNING: Container memory limit unset. Configuring JVM for 1G container.
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx636688K
  -XX:MaxMetaspaceSize=104687K -XX:ReservedCodeCacheSize=240M -Xss1M
  (Total Memory: 1G, Thread Count: 50, Loaded Class Count: 16069, Headroom: 0%)
Adding 138 container CA certificates to JVM truststore
Spring Cloud Bindings Enabled
Picked up JAVA_TOOL_OPTIONS:
-Djava.security.properties=/layers/paketo-buildpacks_bellsoft-liberica/
    java-security-properties/java-security.properties
  -agentpath:/layers/paketo-buildpacks_bellsoft-liberica/jvmkill/
    jvmkill-1.16.0-RELEASE.so=printHeapHistogram=1
  -XX:ActiveProcessorCount=6
  -XX:MaxDirectMemorySize=10M
  -Xmx636688K
  -XX:MaxMetaspaceSize=104687K
  -XX:ReservedCodeCacheSize=240M
  -Xss1M
  -Dorg.springframework.cloud.bindings.boot.enable=true

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.0)

: Starting AircraftPositionsApplication v0.0.1-SNAPSHOT
: Netty started on port(s): 8080
: Started AircraftPositionsApplication in 10.7 seconds (JVM running for 11.202)

现在快速查看一个由 Spring Boot 插件创建的映像内部。

用于检查 Spring Boot 应用程序容器映像的实用程序

存在许多用于处理容器映像的实用程序,它们的功能大部分超出了本书的范围。我想简要提到两个在某些情况下我发现很有用的工具:packdive

Pack

要检查使用 Cloud Native(Paketo)Buildpacks 创建 Spring Boot 应用程序容器映像的材料及其 buildpacks 本身,可以使用pack实用程序。 pack是指定用于使用 Cloud Native Buildpacks 构建应用程序的 CLI,并且可以通过各种方式获取。我在我的 Mac 上使用homebrew来检索并安装它,只需简单的brew install pack命令。

运行pack对先前创建的映像的影响如下:

» pack inspect-image hecklerm/aircraft-positions
Inspecting image: hecklerm/aircraft-positions

REMOTE:

Stack: io.buildpacks.stacks.bionic

Base Image:
  Reference: f5caea10feb38ae882a9447b521fd1ea1ee93384438395c7ace2d8cfaf808e3d
  Top Layer: sha256:1e5c1d306847275caa0d1d367382dfdcfd4d62b634b237f1d7a2e
             746372922cd

Run Images:
  index.docker.io/paketobuildpacks/run:base-cnb
  gcr.io/paketo-buildpacks/run:base-cnb

Buildpacks:
  ID                                         VERSION
  paketo-buildpacks/ca-certificates          1.0.1
  paketo-buildpacks/bellsoft-liberica        5.2.1
  paketo-buildpacks/executable-jar           3.1.3
  paketo-buildpacks/dist-zip                 2.2.2
  paketo-buildpacks/spring-boot              3.5.0

Processes:
  TYPE           SHELL  COMMAND  ARGS
  web (default)  bash   java     org.springframework.boot.loader.JarLauncher
  executable-jar bash   java     org.springframework.boot.loader.JarLauncher
  task           bash   java     org.springframework.boot.loader.JarLauncher

LOCAL:

Stack: io.buildpacks.stacks.bionic

Base Image:
  Reference: f5caea10feb38ae882a9447b521fd1ea1ee93384438395c7ace2d8cfaf808e3d
  Top Layer: sha256:1e5c1d306847275caa0d1d367382dfdcfd4d62b634b237f1d7a2e
             746372922cd

Run Images:
  index.docker.io/paketobuildpacks/run:base-cnb
  gcr.io/paketo-buildpacks/run:base-cnb

Buildpacks:
  ID                                         VERSION
  paketo-buildpacks/ca-certificates          1.0.1
  paketo-buildpacks/bellsoft-liberica        5.2.1
  paketo-buildpacks/executable-jar           3.1.3
  paketo-buildpacks/dist-zip                 2.2.2
  paketo-buildpacks/spring-boot              3.5.0

Processes:
  TYPE           SHELL  COMMAND  ARGS
  web (default)  bash   java     org.springframework.boot.loader.JarLauncher
  executable-jar bash   java     org.springframework.boot.loader.JarLauncher
  task           bash   java     org.springframework.boot.loader.JarLauncher

使用pack实用程序的inspect-image命令提供有关映像的一些关键信息,特别是以下信息:

  • 使用哪个 Docker 基础映像/ Linux 版本(bionic)作为此映像的基础

  • 使用的哪些 buildpacks 来填充映像(列出了五个 Paketo buildpacks)

  • 将通过什么方式运行进程(由外壳执行的 Java 命令)

请注意,将针对指定映像轮询本地和远程连接的存储库,并为两者提供详细信息。这在诊断由于某个位置的过时容器映像引起的问题时尤为有帮助。

Dive

dive实用程序由 Alex Goodman 创建,作为“潜入”容器映像的一种方法,查看非常细粒度的 OCI 映像层和整个映像文件系统的树结构。

dive深入到 Spring Boot 层次结构的应用程序级别以下,并进入操作系统层面。我认为它不如pack有用,因为它更专注于操作系统而不是应用程序,但在验证特定文件的存在或缺失、文件权限和其他重要的低级问题时,它是理想的工具。这是一个很少使用的工具,但在需要这种详细级别的细节和控制时是必不可少的。

代码检出检查

要获取完整的章节代码,请从代码仓库中检出chapter11end分支。

总结

直到应用程序的用户能够真正使用该应用程序,它仅仅是一种假设的实践。在象征性和往往非常字面的意义上,部署是回报。

许多开发人员知道 Spring Boot 应用程序可以创建为 WAR 文件或 JAR 文件。大多数开发人员也知道有许多理由可以跳过 WAR 选项并创建可执行的 JAR 文件,而很少有理由反其道而行。但许多开发人员可能没有意识到,即使在构建 Spring Boot 可执行 JAR 文件时,也有许多部署选项可以满足各种需求和用例。

在本章中,我探讨了几种部署 Spring Boot 应用程序的方式,这些方式适用于不同的目标环境,并讨论了它们的相对优劣。然后我演示了如何创建这些部署工件,解释了最佳执行选项,并展示了如何验证它们的组件和来源。目标包括标准的 Spring Boot 可执行 JAR 文件,“全面可执行”的 Spring Boot JAR 文件,解压/展开的 JAR 文件,以及使用 Cloud Native(Paketo)Buildpacks 构建的容器镜像,这些镜像可以在 Docker、Kubernetes 以及所有主要的容器引擎/机制上运行。Spring Boot 为您提供了多种无摩擦的部署选项,将您的开发超能力延伸到部署超能力领域。

在下一章,也是最后一章中,我会深入探讨两个略微更深入的主题,来为这本书和旅程画上句号。如果您想了解更多有关测试和调试响应式应用程序的内容,千万不要错过。

第十二章:深入探讨响应式编程

正如之前讨论的,响应式编程为开发人员提供了一种在分布式系统中更好地利用资源的方法,甚至将强大的扩展机制扩展到应用程序边界和通信通道中。对于仅有主流 Java 开发实践经验的开发人员(通常被称为命令式Java,因其显式和顺序逻辑,与响应式编程中通常使用的更声明式方法相对),这些响应式能力可能带来一些不希望的成本。除了预期的学习曲线外,Spring 通过并行和互补的 WebMVC 和 WebFlux 实现大大降低了这种学习曲线,还存在工具、成熟度和针对关键活动(如测试、故障排除和调试)的已建立实践的相对限制。

尽管相对于其命令式表亲而言,响应式 Java 开发确实还处于起步阶段,但它们同属一个大家庭的事实,已经允许了更快的工具和流程的发展和成熟。如前所述,Spring 在其开发和社区中建立的成熟命令式专业知识基础上,已经将数十年的演变压缩成现在可用的生产就绪组件。

本章介绍并解释了测试和诊断/调试问题的最新技术,您可能在部署响应式 Spring Boot 应用程序时会遇到,并展示了如何在生产之前,并帮助您进行生产之前,使 WebFlux/Reactor 发挥作用。

代码检查检查完成

请查看代码仓库中的chapter12begin分支开始。

何时使用响应式?

响应式编程,特别是那些专注于响应式流的应用程序,使得系统范围的扩展难以用其他现有手段匹敌。然而,并非所有应用程序都需要在端到端可扩展性的极端要求下运行,或者它们可能已经表现得非常出色,可以在可预见的时间范围内处理相对可预测的负载。命令式应用程序长期以来一直满足全球组织的生产需求,它们不会仅仅因为有了新选项就停止工作。

尽管响应式编程在其提供的可能性方面毫无疑问地令人兴奋,Spring 团队明确表示,响应式代码在可预见的未来,甚至可能永远也不会取代所有命令式代码。正如在Spring WebFlux 的参考文档中所述

如果你有一个庞大的团队,请记住在转向非阻塞、函数式和声明式编程时的陡峭学习曲线。一个实际的开始方法,而不是完全转变,是使用响应式的 WebClient。除此之外,从小处开始,并测量收益。我们预期,在广泛应用的情况下,这种转变是不必要的。如果你不确定要寻找什么样的好处,首先了解非阻塞 I/O 的工作方式(例如,单线程 Node.js 上的并发)及其影响是个不错的开始。

Spring 框架参考文档

简而言之,采用响应式编程和 Spring WebFlux 是一个选择——这是一个极好的选择,可能是实现某些需求的最佳方式——但在仔细考虑所涉及系统的相关需求和要求后再做出的选择。无论是响应式还是非响应式,Spring Boot 都提供了无与伦比的选项来开发处理所有生产工作负载的关键业务软件。

测试响应式应用程序

为了更好地专注于测试响应式 Spring Boot 应用程序的关键概念,我采取了几个步骤来缩小考虑范围的代码范围。就像放大你希望拍摄的主题一样,其他项目代码仍然存在,但不在本节信息的关键路径上。

对于这一部分,我将专注于专门测试那些公开响应式流发布者的 API,如FluxMonoPublisher类型,这些类型可以是FluxMono,而不是典型的阻塞IterableObject类型。我首先从提供外部 API 的Aircraft Positions类内部开始,即PositionController

提示

如果你还没有检查过第十二章的代码,请立即去看看。

但首先,重构

虽然PositionController内部的代码确实有效,但有点混乱。首要任务是提供更清晰的关注点分离,我开始通过将创建RSocketRequester对象的代码移动到一个@Configuration类中,使其作为 Spring bean 创建,可以在应用程序的任何地方访问:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.rsocket.RSocketRequester;

@Configuration
public class RSocketRequesterConfig {
    @Bean
    RSocketRequester requester(RSocketRequester.Builder builder) {
        return builder.tcp("localhost", 7635);
    }
}

这简化了PositionController的构造函数,将创建RSocketRequester的工作放在了适当的位置,并且远离控制器类。要在PositionController中使用RSocketRequester bean,我只需使用 Spring Boot 的构造函数注入自动装配它:

public PositionController(AircraftRepository repository,
                          RSocketRequester requester) {
    this.repository = repository;
    this.requester = requester;
}
注意

测试 RSocket 连接需要进行集成测试。虽然本节重点是单元测试而不是集成测试,但仍然需要将RSocketRequester的构造与PositionController分离开来,以便隔离和正确地单元测试PositionController

另外一个逻辑来源并不完全属于控制器功能,这次涉及使用AircraftRepository bean 获取、存储和检索飞行器位置。通常,当与特定类无关的复杂逻辑进入该类时,最好将其提取出来,就像我为RSocketRequester bean 所做的那样。为了将这段有些复杂且不相关的代码从PositionController中移出,我创建了一个PositionService类,并将其定义为一个在整个应用程序中可用的@Service bean。@Service注解只是对常用的@Component注解的更为具体的视觉描述:

import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
public class PositionService {
    private final AircraftRepository repo;
    private WebClient client = WebClient.create(
        "http://localhost:7634/aircraft");

    public PositionService(AircraftRepository repo) {
        this.repo = repo;
    }

    public Flux<Aircraft> getAllAircraft() {
        return repo.deleteAll()
                .thenMany(client.get()
                        .retrieve()
                        .bodyToFlux(Aircraft.class)
                        .filter(plane -> !plane.getReg().isEmpty()))
                .flatMap(repo::save)
                .thenMany(repo.findAll());
    }

    public Mono<Aircraft> getAircraftById(Long id) {
        return repo.findById(id);
    }

    public Flux<Aircraft> getAircraftByReg(String reg) {
        return repo.findAircraftByReg(reg);
    }
}
注意

当前在AircraftRepository中没有定义findAircraftByReg()方法。在创建测试之前,我解决了这个问题。

尽管可以做更多的工作(特别是关于WebClient成员变量的工作),但现在将PositionService::getAllAircraft中显示的复杂逻辑从其以前的位置移除,并将PositionService bean 注入到控制器中供其使用已足够,这导致控制器类更加干净和专注:

import org.springframework.http.MediaType;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Flux;

@Controller
public class PositionController {
    private final PositionService service;
    private final RSocketRequester requester;

    public PositionController(PositionService service,
            RSocketRequester requester) {
        this.service = service;
        this.requester = requester;
    }

    @GetMapping("/aircraft")
    public String getCurrentAircraftPositions(Model model) {
        model.addAttribute("currentPositions", service.getAllAircraft());

        return "positions";
    }

    @ResponseBody
    @GetMapping(value = "/acstream", produces =
        MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<Aircraft> getCurrentACPositionsStream() {
        return requester.route("acstream")
                .data("Requesting aircraft positions")
                .retrieveFlux(Aircraft.class);
    }
}

回顾现有的PositionController端点显示它们供给了一个 Thymeleaf 模板(public String getCurrentAircraftPositions(Model model))或者需要一个外部的RSocket连接(public Flux<Aircraft> getCurrentACPositionsStream())。为了隔离和测试飞行器位置应用程序提供外部 API 的能力,我需要扩展当前定义的端点。我添加了两个额外的端点映射到/acpos/acpos/search,以创建一个基本但灵活的 API,利用我在PositionService内部创建的方法。

我首先创建了一个方法,以 JSON 格式检索并返回目前位于我们的 PlaneFinder 服务启用设备范围内的所有飞行器的位置。getCurrentACPositions()方法调用PositionService::getAllAircraft,就像它的对应方法getCurrentAircraftPositions(Model model)一样,但它返回 JSON 对象值,而不是将它们添加到领域对象模型并重定向到模板引擎以显示 HTML 页面。

接下来,我创建了一种方法,通过唯一的位置记录标识符和飞行器注册号来搜索当前飞行器位置。记录(在技术上是文档,因为这个版本的Aircraft Positions使用 MongoDB)标识符是数据库中存储的最后从 PlaneFinder 检索到的位置中的唯一 ID。它对于检索特定位置记录很有用;但从飞行器的角度来看,根据飞行器的唯一注册号进行搜索更有用。

有趣的是,PlaneFinder 在查询时可能会报告单个飞机发送的少量位置。这是由于正在飞行的飞机几乎不间断地发送位置报告。对于我们来说,这意味着当根据飞机的唯一注册号在当前报告的位置中进行搜索时,实际上我们可能会检索到该航班的 1+位置报告。

有多种方法可以编写具有灵活性的搜索机制,以接受不同类型的不同搜索条件,并返回不同数量的潜在结果,但我选择将所有选项合并到单个方法中:

@ResponseBody
@GetMapping("/acpos/search")
public Publisher<Aircraft>
        searchForACPosition(@RequestParam Map<String, String> searchParams) {

    if (!searchParams.isEmpty()) {
        Map.Entry<String, String> setToSearch =
                searchParams.entrySet().iterator().next();

        if (setToSearch.getKey().equalsIgnoreCase("id")) {
            return service.getAircraftById(Long.valueOf(setToSearch.getValue()));
        } else {
            return service.getAircraftByReg(setToSearch.getValue());
        }
    } else {
        return Mono.empty();
    }
}

最终(暂时)版本的PositionController类应该如下所示:

import org.reactivestreams.Publisher;
import org.springframework.http.MediaType;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.Map;

@Controller
public class PositionController {
    private final PositionService service;
    private final RSocketRequester requester;

    public PositionController(PositionService service,
            RSocketRequester requester) {
        this.service = service;
        this.requester = requester;
    }

    @GetMapping("/aircraft")
    public String getCurrentAircraftPositions(Model model) {
        model.addAttribute("currentPositions", service.getAllAircraft());

        return "positions";
    }

    @ResponseBody
    @GetMapping("/acpos")
    public Flux<Aircraft> getCurrentACPositions() {
        return service.getAllAircraft();
    }

    @ResponseBody
    @GetMapping("/acpos/search")
    public Publisher<Aircraft> searchForACPosition(@RequestParam Map<String,
            String> searchParams) {

        if (!searchParams.isEmpty()) {
            Map.Entry<String, String> setToSearch =
                searchParams.entrySet().iterator().next();

            if (setToSearch.getKey().equalsIgnoreCase("id")) {
                return service.getAircraftById(Long.valueOf
                    (setToSearch.getValue()));
            } else {
                return service.getAircraftByReg(setToSearch.getValue());
            }
        } else {
            return Mono.empty();
        }
    }

    @ResponseBody
    @GetMapping(value = "/acstream", produces =
            MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<Aircraft> getCurrentACPositionsStream() {
        return requester.route("acstream")
                .data("Requesting aircraft positions")
                .retrieveFlux(Aircraft.class);
    }
}

接下来,我回到PositionService类。正如前面提到的,它的public Flux<Aircraft> getAircraftByReg(String reg)方法引用了AircraftRepository中当前未定义的方法。为了解决这个问题,我在AircraftRepository接口定义中添加了一个Flux<Aircraft> findAircraftByReg(String reg)方法:

import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;

public interface AircraftRepository extends
        ReactiveCrudRepository<Aircraft, Long> {
    Flux<Aircraft> findAircraftByReg(String reg);
}

这段有趣的代码,这个单一的方法签名,展示了使用一组广泛适用的约定的强大 Spring Data 概念:像findsearchget这样的操作符,存储/检索/管理的对象类型(在本例中为Aircraft),以及成员变量名称如reg。通过使用参数+类型和返回类型声明方法签名,并使用提到的方法命名约定,Spring Data 可以为您构建方法实现。

如果您希望或需要提供更多具体或提示,也可以注释方法签名,并提供所需的细节。对于这种情况并不需要,因为声明我们希望通过注册号搜索飞机位置,并在响应式流Flux中返回 0+值就足够 Spring Data 创建实现。

回到PositionService,IDE 现在高兴地报告repo.findAircraftByReg(reg)是一个有效的方法调用。

注意

我为这个示例做出的另一个设计决策是让getAircraftByXxx方法都查询当前位置文档。这可能被认为假定数据库中存在一些位置文档,或者用户对如果数据库中尚未包含任何位置不感兴趣的情况。您的需求可能推动做出不同的选择,例如在搜索之前验证某些位置是否存在,并且如果不存在,则执行一个使用getAllAircraft调用进行新的检索。

现在,进行测试

在早期的测试章节中,使用了标准的Object类型来测试预期结果。我确实使用了WebClientWebTestClient,但只作为与所有基于 HTTP 的端点交互的首选工具,无论它们是否返回响应式流发布者类型。现在,是时候正确测试这些响应式流语义了。

我将现有的PositionControllerTest类作为起点,重新调整以适应其对应类PositionController公开的新的响应式端点。以下是类级别的细节:

@WebFluxTest(controllers = {PositionController.class})
class PositionControllerTest {
    @Autowired
    private WebTestClient client;

    @MockBean
    private PositionService service;
    @MockBean
    private RSocketRequester requester;

    private Aircraft ac1, ac2, ac3;

    ...

}

首先,我使用类级别的注解@WebFluxTest(controllers = {PositionController.class})。我仍然使用响应式的WebTestClient,并希望将此测试类的范围限制在 WebFlux 功能范围内,因此加载完整的 Spring Boot 应用程序上下文是不必要且浪费时间和资源的。

其次,我自动装配了一个WebTestClient bean。在早期关于测试的章节中,我直接将WebTestClient bean 注入到单个测试方法中,但由于现在它将在多个方法中需要使用,因此创建一个成员变量来引用它更加合理。

第三步,我使用 Mockito 的@MockBean注解创建了模拟 Bean。我简单地模拟了RSocketRequester bean,因为PositionController需要一个RSocketRequester bean,无论是真实的还是模拟的。我模拟了PositionService bean,以便在这个类的测试中模拟并使用其行为。模拟PositionService允许我确保其正确行为,同时测试其输出(PositionController),并将实际结果与预期结果进行比较。

最后,我创建了三个Aircraft实例用于包含的测试中。

在执行 JUnit 的@Test方法之前,会运行一个使用@BeforeEach注解的方法来配置场景和预期结果。这是我在每个测试方法之前使用的setUp()方法,用于准备测试环境:

@BeforeEach
void setUp(ApplicationContext context) {
    // Spring Airlines flight 001 en route, flying STL to SFO,
    // at 30000' currently over Kansas City
    ac1 = new Aircraft(1L, "SAL001", "sqwk", "N12345", "SAL001",
            "STL-SFO", "LJ", "ct",
            30000, 280, 440, 0, 0,
            39.2979849, -94.71921, 0D, 0D, 0D,
            true, false,
            Instant.now(), Instant.now(), Instant.now());

    // Spring Airlines flight 002 en route, flying SFO to STL,
    // at 40000' currently over Denver
    ac2 = new Aircraft(2L, "SAL002", "sqwk", "N54321", "SAL002",
            "SFO-STL", "LJ", "ct",
            40000, 65, 440, 0, 0,
            39.8560963, -104.6759263, 0D, 0D, 0D,
            true, false,
            Instant.now(), Instant.now(), Instant.now());

    // Spring Airlines flight 002 en route, flying SFO to STL,
    // at 40000' currently just past DEN
    ac3 = new Aircraft(3L, "SAL002", "sqwk", "N54321", "SAL002",
            "SFO-STL", "LJ", "ct",
            40000, 65, 440, 0, 0,
            39.8412964, -105.0048267, 0D, 0D, 0D,
            true, false,
            Instant.now(), Instant.now(), Instant.now());

    Mockito.when(service.getAllAircraft()).thenReturn(Flux.just(ac1, ac2, ac3));
    Mockito.when(service.getAircraftById(1L)).thenReturn(Mono.just(ac1));
    Mockito.when(service.getAircraftById(2L)).thenReturn(Mono.just(ac2));
    Mockito.when(service.getAircraftById(3L)).thenReturn(Mono.just(ac3));
    Mockito.when(service.getAircraftByReg("N12345"))
        .thenReturn(Flux.just(ac1));
    Mockito.when(service.getAircraftByReg("N54321"))
        .thenReturn(Flux.just(ac2, ac3));
}

我为注册号为 N12345 的飞机分配了飞机位置给ac1成员变量。对于ac2ac3,我为同一架飞机 N54321 分配了非常接近的位置,模拟了从 PlaneFinder 接收到频繁更新的位置报告的常见情况。

setUp()方法的最后几行定义了PositionService模拟 Bean 在不同方式调用其方法时将提供的行为。与早期关于测试的方法模拟类似,唯一的重要区别在于返回值的类型;因为实际的PositionService方法返回 Reactor 的Publisher类型的FluxMono,所以模拟方法也必须如此。

测试以检索所有飞机位置为目的。

最后,我创建了一个方法来测试PositionController的方法getCurrentACPositions()

@Test
void getCurrentACPositions() {
    StepVerifier.create(client.get()
            .uri("/acpos")
            .exchange()
            .expectStatus().isOk()
            .expectHeader().contentType(MediaType.APPLICATION_JSON)
            .returnResult(Aircraft.class)
            .getResponseBody())
        .expectNext(ac1)
        .expectNext(ac2)
        .expectNext(ac3)
        .verifyComplete();
}

测试响应式流应用程序可能带来多种挑战,通常被认为是设置预期结果、获取实际结果并比较二者以确定测试成功或失败的一个相当乏味(如果容易遗漏)的努力。尽管可以有效地即时获取多个结果,就像阻塞类型的Iterable一样,响应式流Publishers并不等待完整的结果集再返回为一个单一单元。从机器的角度来看,这就是一次性接收一组五个结果(例如)或非常快速地接收五个结果,但是单独接收的差异。

Reactor 测试工具的核心是StepVerifier及其实用方法。StepVerifier订阅Publisher,正如其名称所示,使开发人员能够将获得的结果视为离散值并逐个验证。在对getCurrentACPositions的测试中,我执行以下操作:

  • 创建一个StepVerifier

  • 提供由以下步骤产生的Flux

    • 使用WebTestClient bean。

    • 访问映射到*/acpos*端点的PositionController::getCurrentACPositions方法。

    • 初始化exchange()

    • 验证200 OK的响应状态。

    • 验证响应头具有“application/json”的内容类型。

    • Aircraft类的实例形式返回结果项。

    • 获取响应。

  • 评估实际的第一个值与预期的第一个值ac1

  • 评估实际的第二个值与预期的第二个值ac2

  • 评估实际的第三个值与预期的第三个值ac3

  • 验证所有操作并接收Publisher完成信号。

这是对预期行为的相当详尽评估,包括条件和返回值。运行测试的结果输出类似于以下内容(已修剪以适应页面):

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.0)

: Starting PositionControllerTest on mheckler-a01.vmware.com with PID 21211
: No active profile set, falling back to default profiles: default
: Started PositionControllerTest in 2.19 seconds (JVM running for 2.879)

Process finished with exit code 0

从 IDE 运行,结果将类似于在图 12-1 中显示的内容。

sbur 1201

图 12-1. 成功的测试

测试飞机位置搜索功能

PositionController::searchForACPosition内测试搜索功能至少需要进行两个单独的测试,因为能够通过数据库文档 ID 和飞机注册号处理飞机位置搜索。

为了测试通过数据库文档标识符搜索,我创建了以下单元测试:

@Test
void searchForACPositionById() {
    StepVerifier.create(client.get()
            .uri("/acpos/search?id=1")
            .exchange()
            .expectStatus().isOk()
            .expectHeader().contentType(MediaType.APPLICATION_JSON)
            .returnResult(Aircraft.class)
            .getResponseBody())
        .expectNext(ac1)
        .verifyComplete();
}

这与所有飞机位置的单元测试类似。有两个显著的例外:

  • 指定的 URI 引用搜索端点,并包括搜索参数id=1以检索ac1

  • 预期结果仅为ac1,如expectNext(ac1)链式操作中所示。

为了测试通过飞机注册号搜索飞机位置,我创建了以下单元测试,使用我模拟的注册号,包括两个对应的位置文档:

@Test
void searchForACPositionByReg() {
    StepVerifier.create(client.get()
            .uri("/acpos/search?reg=N54321")
            .exchange()
            .expectStatus().isOk()
            .expectHeader().contentType(MediaType.APPLICATION_JSON)
            .returnResult(Aircraft.class)
            .getResponseBody())
        .expectNext(ac2)
        .expectNext(ac3)
        .verifyComplete();
}

这个测试与前一个测试之间的差异很小:

  • URI 包含搜索参数 reg=N54321,应返回ac2ac3,它们都包含了注册编号为 N54321 的飞机的报告位置。

  • 预期结果被验证为ac2ac3,使用 expectNext(ac2)expectNext(ac3) 连接操作。

下面的清单展示了PositionControllerTest类的最终状态:

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.time.Instant;

@WebFluxTest(controllers = {PositionController.class})
class PositionControllerTest {
    @Autowired
    private WebTestClient client;

    @MockBean
    private PositionService service;
    @MockBean
    private RSocketRequester requester;

    private Aircraft ac1, ac2, ac3;

    @BeforeEach
    void setUp() {
        // Spring Airlines flight 001 en route, flying STL to SFO, at 30000'
        // currently over Kansas City
        ac1 = new Aircraft(1L, "SAL001", "sqwk", "N12345", "SAL001",
                "STL-SFO", "LJ", "ct",
                30000, 280, 440, 0, 0,
                39.2979849, -94.71921, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        // Spring Airlines flight 002 en route, flying SFO to STL, at 40000'
        // currently over Denver
        ac2 = new Aircraft(2L, "SAL002", "sqwk", "N54321", "SAL002",
                "SFO-STL", "LJ", "ct",
                40000, 65, 440, 0, 0,
                39.8560963, -104.6759263, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        // Spring Airlines flight 002 en route, flying SFO to STL, at 40000'
        // currently just past DEN
        ac3 = new Aircraft(3L, "SAL002", "sqwk", "N54321", "SAL002",
                "SFO-STL", "LJ", "ct",
                40000, 65, 440, 0, 0,
                39.8412964, -105.0048267, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        Mockito.when(service.getAllAircraft())
                .thenReturn(Flux.just(ac1, ac2, ac3));
        Mockito.when(service.getAircraftById(1L))
                .thenReturn(Mono.just(ac1));
        Mockito.when(service.getAircraftById(2L))
                .thenReturn(Mono.just(ac2));
        Mockito.when(service.getAircraftById(3L))
                .thenReturn(Mono.just(ac3));
        Mockito.when(service.getAircraftByReg("N12345"))
                .thenReturn(Flux.just(ac1));
        Mockito.when(service.getAircraftByReg("N54321"))
                .thenReturn(Flux.just(ac2, ac3));
    }

    @AfterEach
    void tearDown() {
    }

    @Test
    void getCurrentACPositions() {
        StepVerifier.create(client.get()
                .uri("/acpos")
                .exchange()
                .expectStatus().isOk()
                .expectHeader().contentType(MediaType.APPLICATION_JSON)
                .returnResult(Aircraft.class)
                .getResponseBody())
            .expectNext(ac1)
            .expectNext(ac2)
            .expectNext(ac3)
            .verifyComplete();
    }

    @Test
    void searchForACPositionById() {
        StepVerifier.create(client.get()
                .uri("/acpos/search?id=1")
                .exchange()
                .expectStatus().isOk()
                .expectHeader().contentType(MediaType.APPLICATION_JSON)
                .returnResult(Aircraft.class)
                .getResponseBody())
            .expectNext(ac1)
            .verifyComplete();
    }

    @Test
    void searchForACPositionByReg() {
        StepVerifier.create(client.get()
                .uri("/acpos/search?reg=N54321")
                .exchange()
                .expectStatus().isOk()
                .expectHeader().contentType(MediaType.APPLICATION_JSON)
                .returnResult(Aircraft.class)
                .getResponseBody())
            .expectNext(ac2)
            .expectNext(ac3)
            .verifyComplete();
    }
}

PositionControllerTest类中执行所有测试会得到令人满意的结果,如图 12-2 所示。

sbur 1202

图 12-2. 所有单元测试成功执行
注意

StepVerifier 提供了更多的测试可能性,本节已经示意了其中一部分。特别值得关注的是 StepVerifier::withVirtualTime 方法,它可以压缩偶尔发出值的发布者的测试,使得通常会在很长时间内分布的结果立即呈现。StepVerifier::withVirtualTime 接受一个 Supplier<Publisher> 而不是直接的 Publisher,但其使用机制相当类似。

这些是测试响应式 Spring Boot 应用的基本要素。但是,当您在生产环境中遇到问题时会发生什么?当您的应用程序上线时,Reactor 提供了哪些工具来识别和解决问题?

诊断和调试响应式应用

当传统的 Java 应用程序出现问题时,通常会有一个堆栈跟踪。命令式代码可以出于多种原因生成有用的(有时冗长的)堆栈跟踪,但在高层次上,有两个因素使得可以收集和显示这些有用信息:

  • 代码的顺序执行通常决定了如何执行某些操作(命令式)

  • 该顺序代码的执行发生在单个线程内

规则总有例外,但一般来说,这是允许捕获到发生错误的时间点之前顺序执行的步骤的常见组合:所有操作都在单一的泳道中一步步地执行。它可能不会有效地利用系统资源,通常情况下确实不会,但这使得隔离和解决问题变得更加简单。

进入响应式流。Project Reactor 和其他响应式流实现使用调度程序来管理和使用其他线程。通常会保持空闲或低效的资源可以被利用起来,以使得响应式应用能够远远超越其阻塞对应物而扩展。关于如何控制和调整 Schedulers 的选项以及它们可以如何使用的更多详细信息,我建议您查阅Reactor Core 文档,但目前可以简单地说,Reactor 在绝大多数情况下都可以很好地自动处理调度。

然而,这确实突显了为响应式 Spring Boot(或任何响应式)应用程序生成有意义的执行跟踪的一个挑战。人们不能指望简单地跟随单个线程的活动并生成有意义的顺序代码执行列表。

由于这种线程跳转优化特性,追踪执行的难度增加了,这使得响应式编程将代码 组装 与代码 执行 分开。正如在 第八章 中提到的,在大多数情况下对于大多数 Publisher 类型,直到 订阅 之前什么也不会发生。

简而言之,几乎不太可能看到生产故障指向您在声明式组装 Publisher(无论是 Flux 还是 Mono)操作流水线的代码的问题。故障几乎普遍发生在流水线变得活跃时:产生、处理和传递值给 Subscriber

由于代码组装与执行之间的距离以及 Reactor 利用多线程完成操作链的能力,需要更好的工具来有效地排查运行时出现的错误。幸运的是,Reactor 提供了几个优秀的选择。

Hooks.onOperatorDebug()

这并不意味着使用现有堆栈跟踪结果无法解决反应性应用程序的故障排除问题,只是可以显著改进。就像大多数事物一样,证据在于代码——或者在这种情况下,记录的失败输出。

为了模拟反应式 Publisher 操作链的故障,我重新访问了 PositionControllerTest 类,并在每次测试执行前运行的 setUp() 方法中更改了一行代码:

Mockito.when(service.getAllAircraft()).thenReturn(Flux.just(ac1, ac2, ac3));

我用包含结果流中错误的方式替换了由模拟 getAllAircraft() 方法生成的正常运行的 Flux

Mockito.when(service.getAllAircraft()).thenReturn(
        Flux.just(ac1, ac2, ac3)
                .concatWith(Flux.error(new Throwable("Bad position report")))
);

接下来,我执行 getCurrentACPositions() 的测试,以查看我们故意对 Flux 进行破坏的结果(包装以适应页面):

500 Server Error for HTTP GET "/acpos"

java.lang.Throwable: Bad position report
	at com.thehecklers.aircraftpositions.PositionControllerTest
        .setUp(PositionControllerTest.java:59) ~[test-classes/:na]
	Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
	|_ checkpoint ⇢ Handler com.thehecklers.aircraftpositions
        .PositionController
        #getCurrentACPositions() [DispatcherHandler]
	|_ checkpoint ⇢ HTTP GET "/acpos" [ExceptionHandlingWebHandler]
Stack trace:
		at com.thehecklers.aircraftpositions.PositionControllerTest
        .setUp(PositionControllerTest.java:59) ~[test-classes/:na]
		at java.base/jdk.internal.reflect.NativeMethodAccessorImpl
        .invoke0(Native Method) ~[na:na]
		at java.base/jdk.internal.reflect.NativeMethodAccessorImpl
        .invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
		at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl
        .invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
		at java.base/java.lang.reflect.Method
        .invoke(Method.java:564) ~[na:na]
		at org.junit.platform.commons.util.ReflectionUtils
        .invokeMethod(ReflectionUtils.java:686)
        ~[junit-platform-commons-1.6.2.jar:1.6.2]
		at org.junit.jupiter.engine.execution.MethodInvocation
        .proceed(MethodInvocation.java:60)
                ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.InvocationInterceptorChain
        $ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
        ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.extension.TimeoutExtension
        .intercept(TimeoutExtension.java:149)
        ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.extension.TimeoutExtension
        .interceptLifecycleMethod(TimeoutExtension.java:126)
        ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.extension.TimeoutExtension
        .interceptBeforeEachMethod(TimeoutExtension.java:76)
        ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution
        .ExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod
          $0(ExecutableInvoker.java:115)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.ExecutableInvoker
        .lambda$invoke$0(ExecutableInvoker.java:105)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.InvocationInterceptorChain
        $InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.InvocationInterceptorChain
        .proceed(InvocationInterceptorChain.java:64)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.InvocationInterceptorChain
        .chainAndInvoke(InvocationInterceptorChain.java:45)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.InvocationInterceptorChain
        .invoke(InvocationInterceptorChain.java:37)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.ExecutableInvoker
        .invoke(ExecutableInvoker.java:104)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.ExecutableInvoker
        .invoke(ExecutableInvoker.java:98)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor
        .invokeMethodInExtensionContext(ClassBasedTestDescriptor.java:481)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor
        .lambda$synthesizeBeforeEachMethodAdapter
          $18(ClassBasedTestDescriptor.java:466)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .lambda$invokeBeforeEachMethods$2(TestMethodTestDescriptor.java:169)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .lambda$invokeBeforeMethodsOrCallbacksUntilExceptionOccurs
            $5(TestMethodTestDescriptor.java:197)
            ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .invokeBeforeMethodsOrCallbacksUntilExceptionOccurs
            (TestMethodTestDescriptor.java:197)
            ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .invokeBeforeEachMethods(TestMethodTestDescriptor.java:166)
            ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .execute(TestMethodTestDescriptor.java:133)
            ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .execute(TestMethodTestDescriptor.java:71)
            ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$5(NodeTestTask.java:135)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$7(NodeTestTask.java:125)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.Node
        .around(Node.java:135) ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$8(NodeTestTask.java:123)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .executeRecursively(NodeTestTask.java:122)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .execute(NodeTestTask.java:80)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at java.base/java.util.ArrayList.forEach(ArrayList.java:1510) ~[na:na]
		at org.junit.platform.engine.support.hierarchical
        .SameThreadHierarchicalTestExecutorService
            .invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
                ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$5(NodeTestTask.java:139)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$7(NodeTestTask.java:125)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.Node
        .around(Node.java:135) ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$8(NodeTestTask.java:123)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .executeRecursively(NodeTestTask.java:122)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .execute(NodeTestTask.java:80)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at java.base/java.util.ArrayList.forEach(ArrayList.java:1510) ~[na:na]
		at org.junit.platform.engine.support.hierarchical
        .SameThreadHierarchicalTestExecutorService
            .invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
                ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$5(NodeTestTask.java:139)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$7(NodeTestTask.java:125)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.Node
        .around(Node.java:135) ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$8(NodeTestTask.java:123)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .executeRecursively(NodeTestTask.java:122)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .execute(NodeTestTask.java:80)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical
        .SameThreadHierarchicalTestExecutorService
            .submit(SameThreadHierarchicalTestExecutorService.java:32)
                ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical
        .HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical
        .HierarchicalTestEngine.execute(HierarchicalTestEngine.java:51)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.launcher.core.DefaultLauncher
        .execute(DefaultLauncher.java:248)
            ~[junit-platform-launcher-1.6.2.jar:1.6.2]
		at org.junit.platform.launcher.core.DefaultLauncher
        .lambda$execute$5(DefaultLauncher.java:211)
            ~[junit-platform-launcher-1.6.2.jar:1.6.2]
		at org.junit.platform.launcher.core.DefaultLauncher
        .withInterceptedStreams(DefaultLauncher.java:226)
            ~[junit-platform-launcher-1.6.2.jar:1.6.2]
		at org.junit.platform.launcher.core.DefaultLauncher
        .execute(DefaultLauncher.java:199)
            ~[junit-platform-launcher-1.6.2.jar:1.6.2]
		at org.junit.platform.launcher.core.DefaultLauncher
        .execute(DefaultLauncher.java:132)
            ~[junit-platform-launcher-1.6.2.jar:1.6.2]
		at com.intellij.junit5.JUnit5IdeaTestRunner
        .startRunnerWithArgs(JUnit5IdeaTestRunner.java:69)
            ~[junit5-rt.jar:na]
		at com.intellij.rt.junit.IdeaTestRunner$Repeater
        .startRunnerWithArgs(IdeaTestRunner.java:33)
            ~[junit-rt.jar:na]
		at com.intellij.rt.junit.JUnitStarter
        .prepareStreamsAndStart(JUnitStarter.java:230)
            ~[junit-rt.jar:na]
		at com.intellij.rt.junit.JUnitStarter
        .main(JUnitStarter.java:58) ~[junit-rt.jar:na]

java.lang.AssertionError: Status expected:<200 OK>
    but was:<500 INTERNAL_SERVER_ERROR>

> GET /acpos
> WebTestClient-Request-Id: [1]

No content

< 500 INTERNAL_SERVER_ERROR Internal Server Error
< Content-Type: [application/json]
< Content-Length: [142]

{"timestamp":"2020-11-09T15:41:12.516+00:00","path":"/acpos","status":500,
        "error":"Internal Server Error","message":"","requestId":"699a523c"}

	at org.springframework.test.web.reactive.server.ExchangeResult
    .assertWithDiagnostics(ExchangeResult.java:209)
	at org.springframework.test.web.reactive.server.StatusAssertions
    .assertStatusAndReturn(StatusAssertions.java:227)
	at org.springframework.test.web.reactive.server.StatusAssertions
    .isOk(StatusAssertions.java:67)
	at com.thehecklers.aircraftpositions.PositionControllerTest
    .getCurrentACPositions(PositionControllerTest.java:90)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl
    .invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl
    .invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl
    .invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:564)
	at org.junit.platform.commons.util.ReflectionUtils
    .invokeMethod(ReflectionUtils.java:686)
	at org.junit.jupiter.engine.execution.MethodInvocation
    .proceed(MethodInvocation.java:60)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain
    $ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
	at org.junit.jupiter.engine.extension.TimeoutExtension
    .intercept(TimeoutExtension.java:149)
	at org.junit.jupiter.engine.extension.TimeoutExtension
    .interceptTestableMethod(TimeoutExtension.java:140)
	at org.junit.jupiter.engine.extension.TimeoutExtension
    .interceptTestMethod(TimeoutExtension.java:84)
	at org.junit.jupiter.engine.execution.ExecutableInvoker
    $ReflectiveInterceptorCall
        .lambda$ofVoidMethod$0(ExecutableInvoker.java:115)
	at org.junit.jupiter.engine.execution.ExecutableInvoker
    .lambda$invoke$0(ExecutableInvoker.java:105)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain
    $InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain
    .proceed(InvocationInterceptorChain.java:64)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain
    .chainAndInvoke(InvocationInterceptorChain.java:45)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain
    .invoke(InvocationInterceptorChain.java:37)
	at org.junit.jupiter.engine.execution.ExecutableInvoker
    .invoke(ExecutableInvoker.java:104)
	at org.junit.jupiter.engine.execution.ExecutableInvoker
    .invoke(ExecutableInvoker.java:98)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
    .lambda$invokeTestMethod$6(TestMethodTestDescriptor.java:212)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
    .invokeTestMethod(TestMethodTestDescriptor.java:208)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
    .execute(TestMethodTestDescriptor.java:137)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
    .execute(TestMethodTestDescriptor.java:71)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$5(NodeTestTask.java:135)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$7(NodeTestTask.java:125)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$8(NodeTestTask.java:123)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .executeRecursively(NodeTestTask.java:122)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .execute(NodeTestTask.java:80)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1510)
	at org.junit.platform.engine.support.hierarchical
    .SameThreadHierarchicalTestExecutorService
        .invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$5(NodeTestTask.java:139)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$7(NodeTestTask.java:125)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$8(NodeTestTask.java:123)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .executeRecursively(NodeTestTask.java:122)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .execute(NodeTestTask.java:80)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1510)
	at org.junit.platform.engine.support.hierarchical
    .SameThreadHierarchicalTestExecutorService
        .invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$5(NodeTestTask.java:139)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$7(NodeTestTask.java:125)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$8(NodeTestTask.java:123)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .executeRecursively(NodeTestTask.java:122)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .execute(NodeTestTask.java:80)
	at org.junit.platform.engine.support.hierarchical
    .SameThreadHierarchicalTestExecutorService
        .submit(SameThreadHierarchicalTestExecutorService.java:32)
	at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor
    .execute(HierarchicalTestExecutor.java:57)
	at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine
    .execute(HierarchicalTestEngine.java:51)
	at org.junit.platform.launcher.core.DefaultLauncher
    .execute(DefaultLauncher.java:248)
	at org.junit.platform.launcher.core.DefaultLauncher
    .lambda$execute$5(DefaultLauncher.java:211)
	at org.junit.platform.launcher.core.DefaultLauncher
    .withInterceptedStreams(DefaultLauncher.java:226)
	at org.junit.platform.launcher.core.DefaultLauncher
    .execute(DefaultLauncher.java:199)
	at org.junit.platform.launcher.core.DefaultLauncher
    .execute(DefaultLauncher.java:132)
	at com.intellij.junit5.JUnit5IdeaTestRunner
    .startRunnerWithArgs(JUnit5IdeaTestRunner.java:69)
	at com.intellij.rt.junit.IdeaTestRunner$Repeater
    .startRunnerWithArgs(IdeaTestRunner.java:33)
	at com.intellij.rt.junit.JUnitStarter
    .prepareStreamsAndStart(JUnitStarter.java:230)
	at com.intellij.rt.junit.JUnitStarter
    .main(JUnitStarter.java:58)
Caused by: java.lang.AssertionError: Status expected:<200 OK>
        but was:<500 INTERNAL_SERVER_ERROR>
	at org.springframework.test.util.AssertionErrors
    .fail(AssertionErrors.java:59)
	at org.springframework.test.util.AssertionErrors
    .assertEquals(AssertionErrors.java:122)
	at org.springframework.test.web.reactive.server.StatusAssertions
    .lambda$assertStatusAndReturn$4(StatusAssertions.java:227)
	at org.springframework.test.web.reactive.server.ExchangeResult
    .assertWithDiagnostics(ExchangeResult.java:206)
	... 66 more

如您所见,单个错误值的信息量相当难以消化。存在有用的信息,但它被过多、不太有用的数据所淹没。

注意

我勉强但有意地包含了上述 Flux 错误产生的完整输出,以显示当 Publisher 遇到错误时导航通常输出的困难,以及如何通过有效的工具显著降低噪音并增强关键信息的信号。找到问题的核心不仅减少了开发中的挫折感,而且在生产中故障排除业务关键应用程序时绝对至关重要。

Project Reactor 包含可通过其 Hooks 类调用的可配置生命周期回调 hooks。其中一个特别有用的操作符,在事情出错时提高信噪比的是 onOperatorDebug()

在实例化失败的Publisher之前调用Hooks.onOperatorDebug()使得所有后续Publisher类型(及其子类型)的汇编时间仪表化成为可能。为了确保在必要的时间捕获必要的信息,通常将调用放置在应用程序的主方法中,如下所示:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import reactor.core.publisher.Hooks;

@SpringBootApplication
public class AircraftPositionsApplication {

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

}

由于我是从测试类演示此功能,我在故意失败的Publisher组装之前的一行插入了Hooks.onOperatorDebug();

Hooks.onOperatorDebug();
Mockito.when(service.getAllAircraft()).thenReturn(
        Flux.just(ac1, ac2, ac3)
                .concatWith(Flux.error(new Throwable("Bad position report")))
);

这个单独的添加并没有消除有点冗长的堆栈跟踪—尽管还有偶尔提供任何额外数据的情况下可能有所帮助—但是对于大多数情况,由onOperatorDebug()添加到日志的树摘要结果使得更快地识别和解决问题。为了保留完整的细节和格式,我在getCurrentACPositions()测试中引入的相同错误的回溯摘要显示在图 12-3 中。

sbur 1203

图 12-3. 调试回溯

在树的顶部是证据:在PositionControllerTest.java的第 68 行使用concatWith引入了Flux错误。由于Hooks.onOperatorDebug()的帮助,识别此问题及其具体位置所花费的时间从几分钟(甚至更多)减少到几秒钟。

为了所有后续Publisher出现的所有汇编指令仪表化,这种单个添加并不是没有成本的;然而,使用钩子来仪表化您的代码在运行时相对昂贵,因为调试模式是全局的,并且在启用后会影响每个响应流Publisher的每个链接操作符。让我们考虑另一种选择。

检查点

与其填充每个可能的Publisher的每个可能的回溯,不如在关键运算符附近设置检查点以协助故障排除。将checkpoint()运算符插入链中的工作方式类似于启用钩子,但仅适用于该操作符链的该段。

有三种检查点变体:

  • 包括回溯的标准检查点

  • 接受描述性String参数并且不包括回溯的轻量检查点

  • 包括回溯的标准检查点,也接受描述性String参数

让我们看看它们的实际表现。

首先,在PositionControllerTest中的setUp()方法中为PositionService::getAllAircraft的模拟方法之前,我删除了Hooks.onOperatorDebug()语句:

//Hooks.onOperatorDebug();      Comment out or remove
Mockito.when(service.getAllAircraft()).thenReturn(
    Flux.just(ac1, ac2, ac3)
        .checkpoint()
        .concatWith(Flux.error(new Throwable("Bad position report")))
        .checkpoint()
);

重新运行getCurrentACPositions()的测试会生成图 12-4 中显示的结果。

sbur 1204

图 12-4. 标准检查点输出

列表顶部的检查点指导我们找到了问题运算符:即触发检查点之前的那个。请注意,仍在收集回溯信息,因为检查点反映了我在PositionControllerTest类的第 64 行插入的实际源代码文件和特定行号。

切换到轻量级检查点将回溯信息收集替换为开发者指定的有用的String描述。虽然标准检查点的回溯收集范围有限,但仍需要比简单存储String更多的资源。如果以足够详细的方式完成,轻量级检查点可以提供定位问题运算符的同样实用性。更新代码以利用轻量级检查点是一件简单的事情:

//Hooks.onOperatorDebug();      Comment out or remove
Mockito.when(service.getAllAircraft()).thenReturn(
    Flux.just(ac1, ac2, ac3)
        .checkpoint("All Aircraft: after all good positions reported")
        .concatWith(Flux.error(new Throwable("Bad position report")))
        .checkpoint("All Aircraft: after appending bad position report")
);

重新运行getCurrentACPositions()测试将产生如图 12-5 所示的结果。

sbur 1205

图 12-5. 轻量级检查点输出

虽然文件和行号坐标不再出现在列表中排名第一的检查点中,但其清晰的描述使得在Flux组装中找到问题运算符变得容易。

偶尔会需要使用一系列极其复杂的运算符来构建一个Publisher。在这些情况下,包含故障排除的描述和完整的回溯信息可能会很有用。为了演示一个非常有限的例子,我再次重构了用于PositionService::getAllAircraft的模拟方法如下:

//Hooks.onOperatorDebug();      Comment out or remove
Mockito.when(service.getAllAircraft()).thenReturn(
    Flux.just(ac1, ac2, ac3)
        .checkpoint("All Aircraft: after all good positions reported", true)
        .concatWith(Flux.error(new Throwable("Bad position report")))
        .checkpoint("All Aircraft: after appending bad position report", true)
);

再次运行getCurrentACPositions()测试将导致输出如图 12-6 所示。

sbur 1206

图 12-6. 带有描述输出的标准检查点

ReactorDebugAgent.init()

有一种方法可以实现在应用程序中为所有Publishers获得完整回溯的好处,就像使用钩子生成的那样,而无需启用调试功能所带来的性能损失。

在 Reactor 项目中有一个名为reactor-tools的库,其中包括一个单独的 Java 代理用于为包含应用程序的代码添加调试信息。 reactor-tools向应用程序添加调试信息,并连接到运行中的应用程序(它是一个依赖项),以跟踪每个后续的Publisher的执行,提供几乎零性能影响的详细回溯信息,类似于使用钩子。因此,在生产环境中启用ReactorDebugAgent后,几乎没有什么坏处,而且有很多好处。

作为一个独立的库,reactor-tools必须手动添加到应用程序的构建文件中。对于飞行器位置应用程序的 Maven pom.xml,我添加了以下条目:

<dependency>
    <groupId>io.projectreactor</groupId>
    <artifactId>reactor-tools</artifactId>
</dependency>

在保存更新的pom.xml后,我刷新/重新导入依赖项,以便在项目中使用ReactorDebugAgent

类似于Hooks.onOperatorDebug()ReactorDebugAgent通常在应用程序的主方法中初始化,然后再运行应用程序。由于我将在一个不加载完整应用程序上下文的测试中演示这一点,我会像之前使用Hooks.onOperatorDebug()一样在构建用于演示运行时执行错误的Flux之前立即插入初始化调用。我还删除了现在不再需要的checkpoint()调用:

//Hooks.onOperatorDebug();
ReactorDebugAgent.init();       // Add this line
Mockito.when(service.getAllAircraft()).thenReturn(
        Flux.just(ac1, ac2, ac3)
                .concatWith(Flux.error(new Throwable("Bad position report")))
);

再次回到getCurrentACPositions()测试,我运行它并得到了类似于由Hooks.onOperatorDebug()提供的摘要树输出的总结,但没有运行时惩罚:

sbur 1207

图 12-7. 由Flux错误导致的 ReactorDebugAgent 输出

还有其他工具可用,虽然它们不直接帮助测试或调试响应式应用程序,但它们可以帮助提高应用程序质量。一个例子是BlockHound,尽管超出了本章的范围,但它可以成为确定应用程序代码或其依赖项中是否隐藏了阻塞调用的有用工具。当然,这些和其他工具正在快速演变和成熟,提供多种方式来提升您的响应式应用程序和系统。

代码检查

对于完整的章节代码,请查看代码库中的chapter12end分支。

总结

响应式编程为开发人员提供了一种在分布式系统中更好地利用资源的方式,甚至将强大的扩展机制延伸到应用程序边界和通信渠道中。对于那些仅具有主流 Java 开发实践经验的开发人员——通常称为命令式Java,因为它采用显式和顺序逻辑,而不是响应式编程中通常使用的更声明式的方法——这些响应式能力可能带来一些不希望的成本。除了预期的学习曲线,Spring 通过并行和互补的 WebMVC 和 WebFlux 实现大大降低了这些成本。此外,工具、成熟度和针对测试、故障排除和调试等基本活动的建立实践也存在相对限制。

尽管响应式 Java 开发相对于其命令式兄弟处于起步阶段,但它们同属一个家族使得有可能更快地开发和成熟出有用的工具和流程。正如前文所述,Spring 同样依赖于其在开发和社区中建立的成熟命令式专业知识,将数十年的演变凝结为现在可用的生产就绪组件。

在本章中,我介绍并详细阐述了测试、诊断和调试问题的当前技术状态,这些问题可能会在您开始部署响应式 Spring Boot 应用程序时遇到。我还演示了如何在生产环境中使用 WebFlux/Reactor 来为您工作,以多种方式测试和排查响应式应用程序,展示了每种可用选项的相对优势。即使现在,您已经有很多工具可供使用,而且前景只会变得更好。

在这本书中,我不得不选择无数个“最佳部分”中的哪些来覆盖,以便以我希望的最佳方式入门并运行 Spring Boot。还有很多内容,我只希望能将书的范围扩展一倍(或三倍)来涵盖更多内容。感谢您在这段旅程中的陪伴;我希望未来能分享更多。祝您在继续使用 Spring Boot 时一切顺利。