精通Spring Boot 3 : 12. Spring Boot 原生应用与 AOT (1)

创建 GraalVM 本地应用程序

要创建一个原生应用程序,我们需要通过安装 GraalVM 工具来准备我们的环境或计算机。我建议从 https://bell-sw.com/pages/downloads/native-image-kit 下载 Liberica 原生映像工具包(NIK)。目前有三个 NIK 版本,分别对应 JDK 11、17 和 21 版本。由于我们使用的是最新的 Spring Boot 3.x 版本,因此至少需要 JDK 17 版本,您可以选择 NIK 23-JDK 17 或 NIK 23-JDK 21。

如果您使用 macOS 或 Linux,可以通过执行以下命令(例如)来使用 SDKMAN! (https://sdkman.io/) 安装它:

sdk install java 22.3.4.r17-nik

注意:您可以通过使用 sdk list java 来查看可用的 graalvm/Liberica 版本;您将在最后一列看到这些版本。

请通过执行以下命令,确保您的终端或环境使用的是该版本:

java -version
openjdk version "17.0.9" 2023-10-17 LTS
OpenJDK Runtime Environment GraalVM 22.3.4 (build 17.0.9+11-LTS)
OpenJDK 64-Bit Server VM GraalVM 22.3.4 (build 17.0.9+11-LTS, mixed mode, sharing)

如果您使用的是 Windows,请按照 GraalVM 团队为 Windows 用户撰写的博客文章中的说明进行操作:https://medium.com/graalvm/using-graalvm-and-native-image-on-windows-10-9954dc071311。或者,您也可以参考 Liberica 网站上的 Windows 安装程序说明:https://bell-sw.com/pages/downloads/native-image-kit。

现在,您可以开始使用 GraalVM 工具了。

创建一个本地用户应用程序

让我们看看如何创建一个原生用户应用。如果您正在跟随本教程并希望使用提供的源代码,请前往 12-native-aot/users 文件夹。如果您想从头开始使用 Spring Initializr(https://start.spring.io),请添加 GraalVM 原生支持、Web、JPA、验证、监控、H2、PostgreSQL 和 Lombok 依赖项,并将组字段设置为 com.apress,将工件和名称字段设置为 users。点击生成按钮,下载项目,解压缩后导入到您喜欢的 IDE 中。

打开 build.gradle 文件,参见列表 12-1。

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.3'
    id 'io.spring.dependency-management' version '1.1.4'
    id 'org.hibernate.orm' version '6.4.1.Final'
    id 'org.graalvm.buildtools.native' version '0.9.28'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}
repositories {
    mavenCentral()
}
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'org.postgresql:postgresql'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    // Web
    implementation 'org.webjars:bootstrap:5.2.3'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
    useJUnitPlatform()
}
hibernate {
    enhancement {
        enableAssociationManagement = true
    }
}

列表 12-1 的 build.gradle 文件

列表 12-1 显示,build.gradle 文件的插件部分现在包含 org.graalvm.buildtools.native 插件,这将帮助我们生成本地应用程序。我们将使用第 11 章中的解决方案。如果你是从头开始,可以直接复制粘贴相同的代码。如果你使用的是 12-native-aot/users 文件夹中的代码,则无需进行任何修改。你应该拥有图 12-1 所示的文件夹结构。


图 12-1 用户应用程序结构

在继续之前,请确保代码能够正常运行。您可以通过命令行或您的 IDE 来执行它。

接下来,我们将创建本地用户应用。在终端中打开,并在用户项目的根目录下执行以下命令:

./gradlew nativeCompile
...
...
BUILD SUCCESSFUL in 1m 45s
9 actionable tasks: 4 executed, 5 up-to-date

这可能需要最多 15 分钟,具体取决于您电脑的处理器和内存(如果您使用的是旧电脑,请耐心等待)。我使用的是一台配有 M3 芯片的 Mac,因此速度很快。

如果您使用的是 Maven,可以执行以下命令:

./mvnw -Pnative native:compile

之前的构建命令(无论是 Gradle 还是 Maven)会在 Gradle 的 build/native/nativeCompile/users 文件夹中生成可执行文件,或者在 Maven 的 users/target/users 文件夹中生成。

让我们回顾一下在运行应用程序之前发生的事情。当我们执行之前的命令时,会经历几个阶段。首先,AOT 处理开始,生成所有必要的 JSON 配置文件(如 proxy-config.json、reflect-config.json、resource-config.json 等),接着进行 GraalVM 的本地编译(请记住,配置文件是 GraalVM 了解您的应用程序如何创建本地应用所必需的)。在这个阶段,有七个内部步骤:初始化、构建、解析方法、内联方法、编译方法和创建更多类。您应该有一个类似于图 12-2 的 Gradle 结构,或者类似于图 12-3 的 Maven 结构。


请注意,无论您使用的是哪个构建工具(Gradle 或 Maven),最终结果都是相同的,只是存放在不同的文件夹中。请查看 UsersApplication_*.java 的源代码。在 UsersApplication__BeanFactoryRegistrations.java 文件中,您将看到所有需要手动注册的 bean,以确保应用程序正常运行。Spring 框架通常会在运行时自动注册所有 bean,但在这种情况下,我们需要向 GraalVM 通知任何动态组件或代理。

所以,如果你对 Spring Framework 或 Spring Boot 的幕后工作感到好奇,这些类就揭示了其中的秘密!

现在,您可以运行用户应用程序。接下来,在终端中输入以下命令(如果您使用的是 Gradle):

build/native/nativeCompile/users

这个应用程序应该启动得更快。我的电脑初始化只花了 0.08 秒,而使用常规 JVM 则需要 45 秒。当然,用户应用程序很小,我们可能无法立刻看到好处,但可以检查它的内存使用情况。

如果我们查看正在运行的用户应用程序的进程 ID (PID),就可以了解到它消耗了多少内存。以下命令可以识别该应用程序使用的总兆字节数:

# We need to get the PID with:
pid=$(ps -fea | grep -E 'nativeCompile.*users' | head -n1 | awk '{print $2}')
# In my case pid=99008
# Then we need to get the Resident Set Size (RSS) from that PID with:
rss=$(ps -o rss= "$pid" | tail -n1)
# The value was rss=189948
# Then we can calculate by dividing by 1024 with:
mem_usage=$(bc <<< "scale=1; ${rss}/1024")
echo $mem_usage megabytes
186.3 megabytes

另外,您也可以通过像 top/htop 这样的监控工具(适用于 Unix,见图 12-4)或 Windows 的任务管理器获取相同的信息。

图 12-4 显示 Unix htop 中 PID 99008 的内存使用情况为 186M

由于用户应用程序包含执行器依赖,并且在 application.properties 文件中已启用,您可以安全地执行 REST 调用,以优雅地关闭用户应用程序:

curl -si -XPOST http://localhost:8080/actuator/shutdown
HTTP/1.1 200
Content-Type: application/vnd.spring-boot.actuator.v3+json
Transfer-Encoding: chunked
Date: Fri, 12 Jan 2024 01:22:44 GMT
{"message":"Shutting down, bye..."}

接下来,我们来对比一下实际的 JVM。为此,只需构建项目以生成 JAR 文件。您可以使用以下命令:

./gradlew build

然后你可以启动这个应用程序

java -jar build/libs/users-0.0.1-SNAPSHOT.jar

首先,启动时间应该在 4 到 8 秒之间。相比之下,这个时间对于一个更大的应用程序(代码更多,服务更多等)来说显得太短了。

接下来,您可以重复执行 Unix 命令来获取内存使用情况并进行比较。大约会达到 420M。

# Get the PID
pid=$(ps -fea | grep -E 'libs.*users' | tail -n1 | awk '{print $2}')
# Getting the RSS
rss=$(ps -o rss= "$pid" | tail -n1)
# Get the Memory usage
mem_usage=$(bc <<< "scale=1; ${rss}/1024")
echo $mem_usage megabytes
  416.5 MB

正如你所看到的,JVM 中的内存使用情况是双重的。你可以使用你选择的监控工具。图 12-5 展示了使用 htop 的结果。

图 12-5 Unix htop 显示进程 ID 282,内存使用量为 416M

此外,您还可以使用 JConsole 查看这些值,具体如图 12-6 和 12-7 所示。



图 12-7JConsole 显示的已分配虚拟内存为 444M

正如你所看到的,JVM 和本地应用之间存在很大差异。此外,如果你开始尝试 API,你会发现使用本地应用在性能上相较于 JVM 有一些优势。

现在,您可以通过向 /actuator/shutdown 端点发送 POST 请求来关闭用户应用。

创建一个本地的复古应用程序

现在,让我们创建 My Retro App 的原生版本。如果您已经下载了源代码,可以在 12-native-aot/myretro 文件夹中找到它。如果您是从头开始使用 Spring Initializr (https://start.spring.io),请添加 GraalVM 原生支持、Web、WebFlux、JPA、验证、Docker Compose 支持、Actuator、H2、PostgreSQL 和 Lombok 这些依赖项。将 Group 字段设置为 com.apress,将 Artifact 和 Name 字段都设置为 myretro。点击生成按钮,下载项目,解压缩后导入到您喜欢的 IDE 中。

我们将使用上一章的相同代码,所以如果你是从头开始,可以复制并粘贴这个结构。当然,我们需要进行一些修改,我会向你展示并解释这些修改。

首先打开 build.gradle 文件,参见列表 12-2。

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.3'
    id 'io.spring.dependency-management' version '1.1.4'
    id 'org.hibernate.orm' version '6.4.1.Final'
    id 'org.graalvm.buildtools.native' version '0.9.28'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}
repositories {
    mavenCentral()
}
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    runtimeOnly    'com.github.loki4j:loki-logback-appender:1.4.1'
    implementation 'io.micrometer:micrometer-tracing-bridge-brave'
    implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
    implementation  'net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.2'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'org.postgresql:postgresql'
    runtimeOnly 'io.micrometer:micrometer-registry-jmx'
    runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
    developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
    useJUnitPlatform()
}
hibernate {
    enhancement {
        enableAssociationManagement = true
    }
}

列表 12-2 的 build.gradle 文件

列表 12-2 显示我们使用了一些执行器和微米计的依赖项。此外,我们还使用了 org.graalvm.buildtools.native 插件,这将帮助我们创建本地应用程序。

如前所述,该项目需要进行一些修改。请记住,My Retro App 是通过 UserClient 接口与 Users App 进行通信的。请参见第 12-3 列。

package com.apress.myretro.client;
import com.apress.myretro.client.model.User;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@HttpExchange(url = "/users", accept = "application/json", contentType = "application/json")
public interface UserClient {
    @GetExchange
           Flux<User> getAllUsers();
          @GetExchange("/{email}")
          Mono<User> getById(@PathVariable String email);
}

示例 12-3 源代码:src/main/java/com/apress/myretro/client/UserClient.java

在 UserClient 接口中,我们使用@HttpExchange 注解来声明如何访问/users 端点。当然,我们还需要配置 UserClientConfig 类,以便在代码中使用这个客户端。请参见清单 12-4。

package com.apress.myretro.client;
import com.apress.myretro.config.RetroBoardProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class UserClientConfig {
    @Bean
    WebClient webClient(RetroBoardProperties retroBoardProperties) {
        return WebClient.builder()
                .defaultHeaders(header ->
                        header.setBasicAuth(retroBoardProperties.getUsersService().getUsername(),
                                retroBoardProperties.getUsersService().getPassword()))
                .baseUrl(retroBoardProperties.getUsersService().getBaseUrl())
                .build();
    }
    @Bean
    UserClient userClient(WebClient webClient) {
        HttpServiceProxyFactory httpServiceProxyFactory =
                HttpServiceProxyFactory.builderFor(WebClientAdapter.create(webClient))
                        .build();
        return httpServiceProxyFactory.createClient(UserClient.class);
    }
}

列表 12-4 源代码:src/main/java/com/apress/myretro/client/UserClientConfig.java

列表 12-4 展示了访问 WebClient 实例所需的配置。您在第 11 章中已经见过这个,但这次我们使用 RetroBoardProperties 来获取用户名、密码和 baseUrl 属性,以确定用户应用程序的运行位置(尽管我们的用户应用程序目前没有安全性,但这就是我们如何将其添加到 WebClient 中的方式)。

接下来,查看 RetroBoardProperties 和 UsersService 类,分别在清单 12-5 和 12-6 中展示。这些类定义了我们将用于连接用户应用程序的配置属性。

package com.apress.myretro.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
@Data
@ConfigurationProperties(prefix = "myretro")
public class RetroBoardProperties {
    @NestedConfigurationProperty
    private UsersService usersService;
}

12-5 src/main/java/com/apress/myretro/config/RetroBoardProperties.java

package com.apress.myretro.config;
import lombok.Data;
@Data
public class UsersService {
    private String baseUrl;
    private String basePath;
    private String username;
    private String password;
}

12-6 src/main/java/com/apress/myretro/config/UsersService.java

RetroBoardProperties 和 UsersService 类是你已经熟悉的内容,但 RetroBoardProperties 新增了一个注解 @NestedConfigurationProperty。如果你有嵌套属性(就像我们这次的情况)并希望创建一个原生应用程序,这个注解是必不可少的。虽然 Spring 在 AOT 处理上为我们提供了帮助,但这种形式的嵌套属性(RetroBoardProperties 和 UsersService)将无法被检测和绑定,因此这个注解在这种情况下显得尤为重要。

你可以稍后将这个应用程序作为本地应用进行测试;你可以注释掉 @NestedConfigurationProperty 并进行编译(虽然会编译,但无法运行)。RetroBoardProperties 类是创建 WebClient 的依赖,因此必须先初始化。如果没有这个标记(@NestedConfigurationProperty 注解),GraalVM 会编译并创建本地应用,但在运行时会失败,因为没有可绑定到属性的值。

应用程序的配置文件在清单 12-7 中展示。

## DataSource
spring.h2.console.enabled=true
spring.datasource.generate-unique-name=false
spring.datasource.name=test-db
spring.jpa.show-sql=true
## Server
server.port=9081
## Docker Compose
spring.docker.compose.readiness.wait=never
## Application
spring.main.web-application-type=servlet
spring.application.name=my-retro-app
logging.pattern.correlation=[${spring.application.name:},%X{traceId:-},%X{spanId:-}]
## Actuator Info
info.developer.name=Felipe
info.developer.email=felipe@email.com
info.api.version=1.0
management.endpoint.env.enabled=true
## Actuator
management.endpoints.web.exposure.include=health,info,metrics,prometheus,shutdown,configprops,env,trace
## Enable shutdown endpoint
management.endpoint.shutdown.enabled=true
## Actuator Observations
management.observations.key-values.application=${spring.application.name}
## Actuator Metrics
management.metrics.distribution.percentiles-histogram.http.server.requests=true
## Actuator Tracing
management.tracing.sampling.probability=1.0
## Actuator Prometheus
management.prometheus.metrics.export.enabled=true
management.metrics.use-global-registry=true
## Users App Service
myretro.users-service.base-url=http://localhost:8080
myretro.users-service.base-path=/users
myretro.users-service.username=admin
myretro.users-service.password=admin

列表 12-7 源文件:src/main/resources/application.properties

应用程序属性文件的新添加包括最后四行,这些行定义了连接到用户应用程序的嵌套属性。

接下来,我们需要将我的复古应用程序转换为本地应用程序。为此,我们首先必须启动所有服务依赖项;请记住,这个应用程序连接到 Grafana/Loki。您应该在根目录(12-native-aot/myretro)中有一个 docker-compose.yaml 文件,里面包含所有必要的服务声明。

因此,您可以在终端窗口中通过以下命令启动服务

docker compose up -d

服务启动后,请执行以下步骤:

./gradlew nativeCompile
...
...
BUILD SUCCESSFUL in 1m 11s
10 actionable tasks: 10 executed

当我编译应用时,我使用了 GraalVM 21(而不是 17),这增加了一个额外的步骤(进行分析),但这并没有花费太多额外的时间,我想这在内部优化了一切,做了一些有益的工作。正如您在前面的输出中看到的,它的耗时少了大约 30 秒,尽管我的复古应用比用户应用要大。

在运行之前,请确保用户应用程序已正常启动,然后您可以执行:

build/native/nativeCompile/myretro
...
...
Completed initialization in 1 ms

接下来,您可以通过访问一些资源来进行尝试。同时,请检查用户应用程序的访问是否正常,方法是访问 /retros/users 端点。您应该在控制台中看到所有来自用户应用程序的用户信息。

让我们来检查一下消耗了多少内存:

# Get the PID
pid=$(ps -fea | grep -E 'Compile.*myretro' | tail -n1 | awk '{print $2}')
# Getting the RSS
rss=$(ps -o rss= "$pid" | tail -n1)
# Get the Memory usage
mem_usage=$(bc <<< "scale=1; ${rss}/1024")
echo $mem_usage megabytes
238.4 megabytes

我的复古应用程序仅占 238.4M。我们也来与 JVM 进行比较。但首先,请关闭该应用程序。

curl -s -XPOST http://localhost:9081/actuator/shutdown
{"message":"Shutting down, bye..."}

接下来,进行构建

./gradlew clean build

并执行它

java -jar build/libs/myretro-0.0.1-SNAPSHOT.jar
# Get the PID
# Getting the RSS
rss=$(ps -o rss= "$pid" | tail -n1)
# Get the Memory usage
mem_usage=$(bc <<< "scale=1; ${rss}/1024")
echo $mem_usage megabytes
511.2 megabytes

是的,内存使用又双叠了。现在,您可以优雅地关闭我的复古应用程序。

curl -s -XPOST http://localhost:9081/actuator/shutdown
{"message":"Shutting down, bye..."}

获取进程 ID(PID)可能会有些困难,这取决于操作系统以及它如何显示信息。有时在执行命令(例如$ ps -aux)时,输出可能会跳到开头或结尾,因此在选择您应用程序的正确 PID 时请务必小心。

请记住,您还可以使用 Grafana (http://localhost:3000) 和 Spring Boot 统计仪表板,以便比较本地应用和 JVM 的性能。

GraalVM 原生镜像,等等……这是怎么回事?

我们刚刚见识到 GraalVM 的强大功能,它能够创建一个本地应用程序,从而提升性能、加快启动速度并优化内存使用。这些优势在我们将应用程序部署到云端时尤为重要,因为我们需要最佳的性能和更低的内存消耗,以便能够添加更多的并发实例。此外,当应用程序关闭或有新版本发布时,快速恢复和重启应用程序也至关重要。在这种情况下,我们可以采用蓝绿部署等解决方案,以确保客户的服务不会中断。

GraalVM 还可以通过结合 Spring Boot 提供的 Cloud Native Buildpacks (CNBs; 参见 https://buildpacks.io/) 的强大功能,帮助实现更快的部署和重启,以及更多功能,从而轻松创建 Docker 镜像。

自 2.3.0 版本以来,Docker 镜像的创建已成为 Spring Boot 插件(Gradle 或 Maven 构建工具)的一部分。默认情况下,它使用 CNB 创建一个开放容器倡议(OCI;请参见 https://opencontainers.org/)镜像。实际上,仅使用 Spring Boot 插件就足以创建 Docker 镜像。如果您没有 GraalVM 依赖,Spring Boot 插件默认会创建一个 JAR 文件,并使用该文件来生成 Docker 镜像,这意味着后台会安装 JRE,以确保您的 JAR 文件能够正常运行。

现在,如果您添加 GraalVM 依赖插件 org.graalvm.buildtools.native,它将启动 AOT 过程,并创建一个包含您的本地应用程序的 Docker 镜像,从而生成本地镜像。那么,让我们来看看如何为我们的项目创建本地镜像。

创建用户应用的本地镜像

创建本地镜像只需运行以下命令:

./gradlew bootBuildImage

就这样。它将首先执行 AOT 过程并创建本地应用程序。接着,它会使用云原生构建包来创建 Docker 镜像。默认情况下,它会生成标签 docker.io/library/users:0.0.1-SNAPSHOT,这意味着您可以通过以下命令来运行您的应用程序:

docker run --rm -p 8080:8080 --platform linux/amd64 docker.io/library/users:0.0.1-SNAPSHOT

在这个命令中,--rm 会在我们停止容器时删除镜像,-p 则将端口 8080 映射到本地的 8080(语法:HOST-POST:CONTAINER-PORT)。我添加 --platform 参数是因为我使用的是 Mac Silicon(M3 芯片),而 Buildpacks 使用的是 Linux AMD 64,我需要进行模拟,这正是这个参数的作用。最后,长名称是默认的镜像名称。

如果您希望使用自己的标签或命名约定,可以在命令行中添加 imageName 参数;例如,我的 Docker ID 是 felipeg48,因此我可以这样生成镜像:

./gradlew bootBuildImage --imageName=felipeg48/users:v0.0.1

这将创建我的镜像,这样我就可以像这样运行它(比原始命令简短一些)

docker run --rm -p 8080:8080 --platform linux/amd64 felipeg48/users:v0.0.1

此外,您可以通过添加 publishImage 参数来发布您的图片。您需要先进行身份验证才能发布该图片:

./gradlew bootBuildImage --imageName=felipeg48/users:v0.0.1 --publishImage

另外,您可以直接在 build.gradle 文件中添加配置:

tasks.named("bootBuildImage") {
    imageName.set("felipeg48/users:v0.0.1")
    publish = true
    docker {
        publishRegistry {
            username = "felipe48"
            password = "myAwesome$ecret"
        }
    }
}

如果您使用 Maven,可以通过 ./mvnw -Pnative spring-boot:build-image 来创建您的本地镜像。

检查用户的本地镜像

让我们来看看我们刚刚创建的 Docker 镜像。为此,您可以从 https://github.com/wagoodman/dive 下载一个名为 dive 的工具。您可以这样运行它:

dive felipeg48/users:v0.0.1

你会看到类似于图 12-8 的内容。

图 12-8 使用潜水工具

该工具提供了 Docker 镜像内部各层的可视化。您可以使用 Tab 键在不同部分之间进行导航。例如,如图 12-8 所示,在“层”部分,您可以选择应用层(使用上下箭头键),以在当前层内容窗格中查看应用程序的路径,此处为/workspace/com.apress.users.UsersApplication 可执行文件。

随意查看每一层。在当前层内容面板中,您可以使用空格键折叠文件夹。