Spring Boot 与 Docker
观察 GraphQL 的实际运行

本指南介绍如何使用Apache Geode的数据管理系统来缓存应用程序代码中的某些调用。

要更全面地了解 Apache Geode 的概念并学习如何从 Apache Geode 访问数据,请参阅指南使用 Apache Geode 访问数据

你要建造什么

你将构建一个服务,该服务从CloudFoundry托管的报价服务请求报价,并将其缓存到Apache Geode中。

然后,你会发现再次获取相同报价时,由于 Spring 的 Cache Abstraction(由 Apache Geode 支持),对 Quote 服务的昂贵调用会被消除,并且会缓存给定请求的结果。

Quote服务的位置是……

https://quoters.apps.pcfone.io

Quote服务提供了以下API……

GET /api         - get all quotes
GET /api/random  - get random quote
GET /api/{id}    - get specific quote

所需内容

如何完成本指南

和大多数 Spring 入门指南一样,你可以从零开始并完成每一步,也可以跳过已熟悉的基本设置步骤。不管选择哪种方式,你最终都能获得可运行的代码。

要从头开始,请参阅使用 Spring Initializr 开始

若要跳过基础知识,请执行以下操作:

完成之后,你可以将你的结果与 gs-caching-gemfire/complete 中的代码进行对比。

从 Spring Initializr 开始

对于所有的 Spring 应用程序,你应该从 Spring Initializr 开始。Spring Initializr 提供了一种快速方式来引入你所需的所有依赖,并为你完成许多设置工作。这个示例需要 "Spring for Apache Geode" 依赖。

以下是一个使用 Maven 时的示例 pom.xml 文件:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.0</version>
    </parent>

    <groupId>org.springframework</groupId>
    <artifactId>gs-caching-gemfire</artifactId>
    <version>0.1.0</version>

    <properties>
        <spring-shell.version>1.2.0.RELEASE</spring-shell.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>javax.cache</groupId>
            <artifactId>cache-api</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-geode</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.shell</groupId>
            <artifactId>spring-shell</artifactId>
            <version>${spring-shell.version}</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

以下是一个使用 Gradle 的示例 build.gradle 文件:

plugins {
    id 'org.springframework.boot' version '2.7.0'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'io.freefair.lombok' version '6.3.0'
    id 'java'
}

apply plugin: 'eclipse'
apply plugin: 'idea'

group = "org.springframework"
version = "0.1.0"
sourceCompatibility = '1.8'

repositories {
    mavenCentral()
}

dependencies {
    implementation "org.springframework.boot:spring-boot-starter"
    implementation "org.springframework.data:spring-data-geode"
    implementation "com.fasterxml.jackson.core:jackson-databind"
    implementation "org.projectlombok:lombok"
    runtimeOnly "javax.cache:cache-api"
    runtimeOnly "org.springframework.shell:spring-shell:1.2.0.RELEASE"
}

创建一个用于获取数据的绑定对象

现在你已经设置好了项目和构建系统,可以专注于定义所需的领域对象,以捕获从Quote服务获取报价的数据。

src/main/java/hello/Quote.java

package hello;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

import org.springframework.util.ObjectUtils;

import lombok.Data;

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@SuppressWarnings("unused")
public class Quote {

  private Long id;

  private String quote;

  @Override
  public boolean equals(Object obj) {

    if (this == obj) {
      return true;
    }

    if (!(obj instanceof Quote)) {
      return false;
    }

    Quote that = (Quote) obj;

    return ObjectUtils.nullSafeEquals(this.getId(), that.getId());
  }

  @Override
  public int hashCode() {

    int hashValue = 17;

    hashValue = 37 * hashValue + ObjectUtils.nullSafeHashCode(getId());

    return hashValue;
  }

  @Override
  public String toString() {
    return getQuote();
  }
}

Quote 域类具有 idquote 属性。这两个属性是你将在本指南后续部分收集的主要信息。通过使用Project LombokQuote 类的实现得到了简化。

除了Quote之外,QuoteResponse 还捕获了报价服务在报价请求中返回的整个响应负载。它包括请求的状态(也称为type)以及quote。此类还使用Project Lombok 来简化实现。

src/main/java/hello/QuoteResponse.java

package hello;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

import lombok.Data;

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class QuoteResponse {

  @JsonProperty("value")
  private Quote quote;

  @JsonProperty("type")
  private String status;

  @Override
  public String toString() {
    return String.format("{ @type = %1$s, quote = '%2$s', status = %3$s }",
      getClass().getName(), getQuote(), getStatus());
  }
}

报价服务的典型响应如下:

{
  "type":"success",
  "value": {
    "id":1,
    "quote":"Working with Spring Boot is like pair-programming with the Spring developers."
  }
}

这两个类都标注了@JsonIgnoreProperties(ignoreUnknown=true)。这意味着即使存在其他的JSON属性,它们也会被忽略。

通过查询报价服务获取数据

你的下一步是创建一个用于查询引用的服务类。

src/main/java/hello/QuoteService.java

package hello;

import java.util.Collections;
import java.util.Map;
import java.util.Optional;

import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@SuppressWarnings("unused")
@Service
public class QuoteService {

  protected static final String ID_BASED_QUOTE_SERVICE_URL = "https://quoters.apps.pcfone.io/api/{id}";
  protected static final String RANDOM_QUOTE_SERVICE_URL = "https://quoters.apps.pcfone.io/api/random";

  private volatile boolean cacheMiss = false;

  private final RestTemplate quoteServiceTemplate = new RestTemplate();

  /**
   * Determines whether the previous service method invocation resulted in a cache miss.
   *
   * @return a boolean value indicating whether the previous service method invocation resulted in a cache miss.
   */
  public boolean isCacheMiss() {
    boolean cacheMiss = this.cacheMiss;
    this.cacheMiss = false;
    return cacheMiss;
  }

  protected void setCacheMiss() {
    this.cacheMiss = true;
  }

  /**
   * Requests a quote with the given identifier.
   *
   * @param id the identifier of the {@link Quote} to request.
   * @return a {@link Quote} with the given ID.
   */
  @Cacheable("Quotes")
  public Quote requestQuote(Long id) {
    setCacheMiss();
    return requestQuote(ID_BASED_QUOTE_SERVICE_URL, Collections.singletonMap("id", id));
  }

  /**
   * Requests a random quote.
   *
   * @return a random {@link Quote}.
   */
  @CachePut(cacheNames = "Quotes", key = "#result.id")
  public Quote requestRandomQuote() {
    setCacheMiss();
    return requestQuote(RANDOM_QUOTE_SERVICE_URL);
  }

  protected Quote requestQuote(String URL) {
    return requestQuote(URL, Collections.emptyMap());
  }

  protected Quote requestQuote(String URL, Map<String, Object> urlVariables) {

    return Optional.ofNullable(this.quoteServiceTemplate.getForObject(URL, QuoteResponse.class, urlVariables))
      .map(QuoteResponse::getQuote)
      .orElse(null);
  }
}

QuoteService 使用 Spring 的 RestTemplate 来查询 Quote 服务的 API。Quote 服务返回一个 JSON 对象,但 Spring 使用 Jackson 将数据绑定到 QuoteResponse 对象,并最终绑定到一个 Quote 对象。

这个服务类的关键部分是 requestQuote 方法被注解为 @Cacheable("Quotes")Spring 的缓存抽象 会拦截对 requestQuote 的调用,检查服务方法是否已经被调用过。如果是这样,Spring 的缓存抽象 就直接返回缓存的副本。否则,Spring 继续执行该方法,将响应存储在缓存中,并将结果返回给调用者。

我们在 requestRandomQuote 服务方法上使用了 @CachePut 注解。由于该方法返回的引用是随机的,我们无法在调用前查询缓存(即 Quotes)。不过,我们可以缓存调用的结果,这将对后续的 requestQuote(id) 调用产生积极影响,前提是感兴趣的引用已被先前随机选择并缓存。

@CachePut 使用 SpEL 表达式(“#result.id”)来访问服务方法调用的结果,并提取 Quote 的 ID 作为缓存键。你可以在这里了解更多关于 Spring 的 Cache 抽象 SpEL 上下文

你必须提供缓存的名称。为了演示目的,我们将它命名为 "Quotes",但在生产环境中,建议选择一个描述性更强的名称。这也意味着不同的方法可以与不同的缓存关联起来。如果你为每个缓存设置了不同的配置(如过期或驱逐策略等),这将非常有用。

稍后当你运行代码时,会看到每个调用的执行时间,并能辨别出缓存对服务响应时间的影响。这说明了缓存特定调用的重要性。如果应用程序频繁查询相同数据,缓存这些结果可以大幅提高性能。

使应用具备可执行性

虽然 Apache Geode 缓存可以嵌入到 Web 应用和 WAR 文件中,但下面演示的方法更为简单,即创建一个独立的应用程序。所有内容被打包在一个可执行的 JAR 文件中,并通过传统的 Java main() 方法来驱动。

src/main/java/hello/Application.java

package hello;

import java.util.Optional;

import org.apache.geode.cache.client.ClientRegionShortcut;

import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.gemfire.cache.config.EnableGemfireCaching;
import org.springframework.data.gemfire.config.annotation.ClientCacheApplication;
import org.springframework.data.gemfire.config.annotation.EnableCachingDefinedRegions;

@SpringBootApplication
@ClientCacheApplication(name = "CachingGemFireApplication")
@EnableCachingDefinedRegions(clientRegionShortcut = ClientRegionShortcut.LOCAL)
@EnableGemfireCaching
@SuppressWarnings("unused")
public class Application {

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

  @Bean
  ApplicationRunner runner(QuoteService quoteService) {

    return args -> {
      Quote quote = requestQuote(quoteService, 12L);
      requestQuote(quoteService, quote.getId());
      requestQuote(quoteService, 10L);
    };
  }

  private Quote requestQuote(QuoteService quoteService, Long id) {

    long startTime = System.currentTimeMillis();

    Quote quote = Optional.ofNullable(id)
      .map(quoteService::requestQuote)
      .orElseGet(quoteService::requestRandomQuote);

    long elapsedTime = System.currentTimeMillis();

    System.out.printf("\"%1$s\"%nCache Miss [%2$s] - Elapsed Time [%3$s ms]%n", quote,
      quoteService.isCacheMiss(), (elapsedTime - startTime));

    return quote;
  }
}

@SpringBootApplication 是一个便利的注解,包含以下所有内容:

  • @Configuration: 表示该类是应用上下文中 bean 定义的来源。

  • @EnableAutoConfiguration: 告诉 Spring Boot 根据类路径设置、其他 Bean 以及各种属性设置自动配置 Bean。例如,如果类路径上存在 spring-webmvc,此注解会将应用程序标识为 Web 应用程序,并激活一些关键行为,如设置 DispatcherServlet

  • @ComponentScan: 告诉 Spring 在 hello 包中扫描并查找其他组件、配置和服务,以便找到控制器。

main() 方法使用 Spring Boot 的 SpringApplication.run() 方法来启动应用。你有没有注意到没有一行 XML 配置?也没有 web.xml 文件。这个 Web 应用是 100% 纯 Java,无需配置任何基础设施。

配置的顶部有一个重要的注解:@EnableGemfireCaching。这个注解启用了缓存(即通过Spring的@EnableCaching 注解进行元注解),并在后台声明了一些支持使用Apache Geode作为缓存提供程序的重要bean。

第一个 bean 是 QuoteService 的实例,用于访问 Quotes RESTful Web 服务。

其他两个用于缓存报价并执行应用程序的操作。

  • quotesRegion 定义了一个 Apache Geode LOCAL 客户端 Region,用于在缓存中存储报价。它被特别命名为 "Quotes",以便与我们在 QuoteService 方法上使用的 @Cacheable("Quotes") 注解相匹配。

  • runner 是一个实现了Spring Boot ApplicationRunner 接口的实例,用于运行我们的应用程序。

当第一次请求报价(使用 requestQuote(id))时,会发生缓存未命中,并且服务方法会被调用,产生明显的延迟,这个延迟远非零毫秒。在这种情况下,缓存是通过服务方法的输入参数(即 id)来链接的,也就是说,id 是缓存键。后续使用相同的 ID 请求同一报价时,将导致缓存命中,从而避免昂贵的服务调用。

为了演示目的,对 QuoteService 的调用被封装在一个单独的方法(即 Application 类中的 requestQuote 方法)中,以便捕获服务调用所需的时间。这样你可以精确地看到每个请求所花费的具体时间。

构建可执行的 JAR 包

你可以通过 Gradle 或 Maven 从命令行运行应用程序。你也可以构建一个包含所有必需依赖项、类和资源的单个可执行 JAR 文件并运行它。这样可以轻松地在整个开发生命周期中,在不同的环境中打包、版本控制和部署服务。

如果你使用 Gradle,可以通过运行 ./gradlew bootRun 来启动应用程序。或者,你也可以通过运行 ./gradlew build 构建 JAR 文件,并按如下方式运行该 JAR 文件:

java -jar build/libs/gs-caching-gemfire-0.1.0.jar

如果你使用 Maven,可以通过运行 ./mvnw spring-boot:run 来启动应用。或者,你可以通过执行 ./mvnw clean package 构建 JAR 文件,然后按照以下方式运行该 JAR 文件:

java -jar target/gs-caching-gemfire-0.1.0.jar
这里描述的步骤创建了一个可执行的 JAR 文件。你也可以构建一个经典的 WAR 文件

显示日志输出,服务将在几秒钟内启动并开始运行。

"@springboot with @springframework is pure productivity! Who said in #java one has to write double the code than in other langs? #newFavLib"
Cache Miss [true] - Elapsed Time [776 ms]
"@springboot with @springframework is pure productivity! Who said in #java one has to write double the code than in other langs? #newFavLib"
Cache Miss [false] - Elapsed Time [0 ms]
"Really loving Spring Boot, makes stand alone Spring apps easy."
Cache Miss [true] - Elapsed Time [96 ms]

从这里可以看出,第一次调用报价服务获取报价耗时776毫秒,导致缓存未命中。然而,第二次请求相同报价的调用仅耗时0毫秒,并且缓存命中。这清楚地表明第二次调用被缓存了,并没有实际访问报价服务。但是,当最终为一个特定的、未缓存的报价请求进行服务调用时,它耗时96毫秒,并且由于这个新报价在调用之前不在缓存中而导致缓存未命中。

摘要

恭喜!你刚刚创建了一个执行昂贵操作的服务,并标记了它以缓存结果。

本页目录