自定义Actuator

Actuator最棒的特性之一就是它能够进行自定义,以满足应用的特定需求。一些端点本身支持自定义扩展,同时,Actuator也允许我们创建完全自定义的端点。

接下来,我们看一下Actuator进行自定义的几种方式。从为“/info”端点添加信息开始。

为 “/info” 端点提供信息

正如我们在15.2.1小节所看到的那样,“/info”最初是空的,不能提供任何信息。我们可以通过创建前缀为“info.”的属性很容易地为它添加数据。

创建前缀为“info.”的属性尽管是一个很简单的为“/info”端点添加自定义数据的方式,但并不是唯一的方式。Spring Boot提供了名为InfoContributor的接口,允许我们以编程的方式为“/info”端点添加任何想要的信息。Spring Boot甚至提供了InfoContributor接口的几个非常实用的实现。

接下来,我们看一下如何编写自定义的InfoContributor,以便向“/info”端点添加自定义信息。

创建自定义的Info贡献者

假设我们想要为“/info”端点添加关于Taco Cloud的统计信息,比如已创建的taco数量。为了实现这一点,需要编写一个实现InfoContributor接口的类,并将TacoRepository注入,然后发布TacoRepository提供的信息到“/info”端点中。程序清单15.3展示了如何实现这样一个贡献者(contributor)。

程序清单15.3 InfoContributor的自定义实现
package tacos.actuator;

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

import org.springframework.boot.actuate.info.Info.Builder;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.stereotype.Component;

import tacos.data.TacoRepository;

@Component
public class TacoCountInfoContributor implements InfoContributor {
  private TacoRepository tacoRepo;

  public TacoCountInfoContributor(TacoRepository tacoRepo) {
    this.tacoRepo = tacoRepo;
  }

  @Override
  public void contribute(Builder builder) {
    long tacoCount = tacoRepo.count().block();
    Map<String, Object> tacoMap = new HashMap<String, Object>();
    tacoMap.put("count", tacoCount);
    builder.withDetail("taco-stats", tacoMap);
  }
}

要实现InfoContributor接口,TacoCountInfoContributor需要实现contribute()方法。这个方法能够获得一个Builder对象,基于这个对象,contribute()调用withDetail()方法来添加详情信息。在上述的实现中,我们通过TacoRepository的count()来获取已创建的taco数量。在本例中,我们使用了一个反应式的存储库,所以需要调用block()方法以便从Mono<Long>中获取具体的数值。然后,我们将这个数值放入一个Map,以值为taco-stats的label将它传递到builder中。这样形成的“/info”端点会包含这个数量,如下所示:

{
  "taco-stats": {
    "count": 44
  }
}

可以看到,InfoContributor的实现可以使用任何必要的方法贡献信息。为属性添加“info.”前缀虽然简单,却仅限于针对静态值的情况。

注入构建信息到“/info”端点

Spring Boot提供了一些内置的InfoContributor实现,能够自动在“/info”端点的结果中添加信息。其中有一个实现是BuildInfoContributor,它能够将项目构建文件中的信息添加到“/info”端点的结果中,包括一些基本信息,如项目版本、构建的时间戳,以及执行构建的主机和用户。

为了将构建信息添加到“/info”端点的结果中,需要添加build-info goal到Spring Boot Maven Plugin executions中,如下所示:

<build>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
      <executions>
        <execution>
          <goals>
            <goal>build-info</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

使用Gradle构建项目,只需要将如下几行代码添加到build.gradle文件中:

springBoot {
  buildInfo()
}

不管使用哪种方式,构建过程都会在可分发的JAR或WAR文件中生成一个名为build-info.properties的文件,BuildInfoContributor会使用这个文件并为“/info”端点贡献信息。如下的“/info”端点响应片段展现了所贡献的构建信息:

{
  "build": {
    "artifact": "tacocloud",
    "name": "taco-cloud",
    "time": "2021-08-08T23:55:16.379Z",
    "version": "0.0.15-SNAPSHOT",
    "group": "sia"
  },
}

这个信息对于我们理解正在运行的应用的确切版本和构建时间是非常有用的。通过向“/info”端点发送GET请求,我们就能知道正在运行的是不是项目的最新构建版本。

暴露Git提交信息

如果我们的项目使用Git进行源码控制,那么我们可以在“/info”端点中包含Git提交信息。为了实现这一点,需要将如下的插件添加到Maven项目的pom.xml文件中:

<build>
  <plugins>
...
    <plugin>
      <groupId>pl.project13.maven</groupId>
      <artifactId>git-commit-id-plugin</artifactId>
    </plugin>
  </plugins>
</build>

你如果是Gradle用户,也不用担心。我们可以将一个功能相同的插件添加到build.gradle文件中:

plugins {
  id "com.gorylenko.gradle-git-properties" version "2.3.1"
}

这两个插件完成的事情是相同的:生成一个名为git.properties的构建期制品。这个文件包含了项目的所有Git元数据。在运行时,有个特殊的InfoContributor实现能够发现这个文件并将它的内容贡献给“/info”端点。

当然,为了生成git.properties文件,项目中需要存在 Git 提交元数据(metadata)。换句话说,它必须克隆自一个Git仓库,或者是一个包含至少一次提交的新本地Git仓库。如果没有提交信息,那么这两个插件都不能发挥作用。不过,我们可以对它们进行配置,以忽略缺少的Git元数据。对于Maven插件,需要将failOnNoGitDirectory属性设置为false,如下所示:

<build>
  <plugins>
...
    <plugin>
      <groupId>pl.project13.maven</groupId>
      <artifactId>git-commit-id-plugin</artifactId>
      <configuration>
        <failOnNoGitDirectory>false</failOnNoGitDirectory>
      </configuration>
    </plugin>
  </plugins>
</build>

类似地,可以在Gradle中像下面一样在gitProperties下指定failOnNoGitDirectory属性:

gitProperties {
  failOnNoGitDirectory = false
}

按照最简单的形式,“/info”端点展现的Git信息包括应用构建所使用的Git分支、提交的哈希值,以及时间戳:

{
  "git": {
    "branch": "main",
    "commit": {
      "id": "df45505",
      "time": "2021-08-08T21:51:12Z"
    }
  },
...
}

这些信息非常清晰地描述了项目构建时代码的状态。此外,我们还可以将management. info.git.mode属性设置为full,从而得到项目构建时的详细Git提交信息。

management:
  info:
    git:
      mode: full

程序清单15.4展现了一个完整的Git信息样例。

程序清单15.4 通过“/info”端点展现完整的Git信息
"git": {
  "local": {
    "branch": {
      "ahead": "8",
      "behind": "0"
    }
  },
  "commit": {
    "id": {
      "describe-short": "df45505-dirty",
      "abbrev": "df45505",
      "full": "df455055daaf3b1347b0ad1d9dca4ebbc6067810",
      "describe": "df45505-dirty"
    },
    "message": {
      "short": "Apply chapter 18 edits",
    "full": "Apply chapter 18 edits"
  },
  "user": {
    "name": "Craig Walls",
    "email": "craig@habuma.com"
  },
  "author": {
    "time": "2021-08-08T15:51:12-0600"
  },
  "committer": {
      "time": "2021-08-08T15:51:12-0600"
    },
    "time": "2021-08-08T21:51:12Z"
  },
  "branch": "master",
  "build": {
    "time": "2021-08-09T00:13:37Z",
    "version": "0.0.15-SNAPSHOT",
    "host": "Craigs-MacBook-Pro.local",
    "user": {
      "name": "Craig Walls",
      "email": "craig@habuma.com"
    }
  },
  "tags": "",
  "total": {
    "commit": {
      "count": "196"
    }
  },
  "closest": {
    "tag": {
        "commit": {
          "count": ""
        },
        "name": ""
    }
  },
  "remote": {
    "origin": {
      "url": "git@github.com:habuma/spring-in-action-6-samples.git"
    }
  },
  "dirty": "true"
},

除了时间戳和Git提交哈希值的缩略值,完整版本的信息还包含了代码提交者的名字和邮箱、完整的提交信息,以及其他内容,便于我们精确定位构建项目所使用的代码。实际上,我们可以看到程序清单15.4中dirty属性的值为true,表明项目构建时构建目录中存在未提交的变更。没有什么信息比这更有说服力了!

实现自定义的健康指示器

Spring Boot提供了多个内置的健康指示器,它们能够提供与Spring应用交互的通用外部系统的健康信息。有时候我们可能会发现,所使用的外部系统在Spring Boot的预料之外,Spring Boot也没有为它提供健康指示器。

例如,我们的应用可能与一个传统大型机应用交互,应用的健康状况会受到遗留系统健康状况的影响。为了创建自定义的健康指示器,我们需要创建一个实现了HealthIndicator接口的bean。

实际上,对于Taco Cloud服务,我们没有必要创建自定义的健康指示器,Spring Boot所提供的指示器就足够用了。为了阐述如何开发自定义的健康指示器,我们看一下程序清单15.5,它展现了一个简单的HealthIndicator实现,健康状况根据一天中的时间判定。

程序清单15.5 HealthIndicator的一个特殊实现
package tacos.actuator;

import java.util.Calendar;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component
public class WackoHealthIndicator
       implements HealthIndicator {
  @Override
  public Health health() {
    int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
    if (hour > 12) {
      return Health
          .outOfService()
          .withDetail("reason",
                 "I'm out of service after lunchtime")
          .withDetail("hour", hour)
          .build();
    }

    if (Math.random() <= 0.1) {
      return Health
          .down()
          .withDetail("reason", "I break 10% of the time")
          .build();
    }
    return Health
        .up()
        .withDetail("reason", "All is good!")
        .build();
  }
}

这个疯狂的健康指示器首先会判断当前是什么时间。如果是下午,那么所返回的健康状态是OUT_OF_SERVICE,其中还包含导致该状态的原因详情。即便是在上午,这个健康指示器也有10%的概率报告DOWN状态,因为它使用随机数来决定应用是否正常启动。如果随机数的值小于0.1,那么状态将是DOWN,否则状态将是UP。

显然,在真正的应用中,程序清单15.5 的健康指示器不会有什么用处。但是,可以假设我们并不是根据当前时间或随机数判定健康状况,而是对外部系统发起一个远程调用,并基于接收到的响应状态来判定,那么它就是一个非常有用的健康指示器了。

注册自定义的指标

在15.2.4小节,我们看到了如何访问“/metrics”端点来消费Actuator发布的各种指标,当时我们主要关注了HTTP请求的信息。Actuator提供的指标非常有用,但是“/metrics”端点的结果并不局限于内置的指标。

实际上,Actuator的指标是由Micrometer实现的。这是一个供应商中立的指标门面,借助它,我们能够发送任意想要的指标,并在所选的第三方监控系统中展现它。它提供了对Prometheus、Datadog、New Relic等系统的支持。

使用Micrometer发布指标的最基本方式是借助Micrometer的MeterRegistry。要在Spring Boot应用中发布指标,唯一需要做的就是将MeterRegistry注入想要发布计数器、计时器或计量器(gauges)的地方,这些地方能够捕获应用的指标信息。

作为发布自定义指标的样例,假设我们想要统计使用不同配料所创建的taco的数量。也就是说,我们想要知道,使用生菜、碎牛肉、墨西哥薄饼以及其他配料分别制作了多少个taco。程序清单15.6中的TacoMetrics bean展示了如何使用MeterRegistry来收集信息。

程序清单15.6 TacoMetrics注册了关于taco配料的指标
package tacos.actuator;

import java.util.List;
import org.springframework.data.rest.core.event.AbstractRepositoryEventListener;
import org.springframework.stereotype.Component;
import io.micrometer.core.instrument.MeterRegistry;
import tacos.Ingredient;
import tacos.Taco;

@Component
public class TacoMetrics extends AbstractRepositoryEventListener<Taco> {
  private MeterRegistry meterRegistry;

  public TacoMetrics(MeterRegistry meterRegistry) {
    this.meterRegistry = meterRegistry;
  }

  @Override
  protected void onAfterCreate(Taco taco) {
    List<Ingredient> ingredients = taco.getIngredients();
    for (Ingredient ingredient : ingredients) {
      meterRegistry.counter("tacocloud",
          "ingredient", ingredient.getId()).increment();
    }
  }
}

可以看到,TacoMetrics通过其构造器注入了MeterRegistry。它还扩展了Abstract RepositoryEventListener,这是Spring Data中的一个类,能够拦截存储库事件。我们重写了onAfterCreate()方法,这样一来,每当保存新的Taco对象,它都会得到通知。

在onAfterCreate()中,我们为每种配料声明了一个计数器,其中标签名为ingredient,标签值为配料ID。如果给定标签的计数器已经存在,就会复用已有的计数器,计数器递增,表明又使用该配料创建了一个taco。

在创建几个taco之后,我们就可以查询“/metrics”端点来获取配料的计数信息了。对“/metrics/tacocloud”发送GET请求将会获得如下未经过滤的指标数据:

$ curl localhost:8080/actuator/metrics/tacocloud
{
  "name": "tacocloud",
  "measurements": [ { "statistic": "COUNT", "value": 84 }
  ],
  "availableTags": [
    {
      "tag": "ingredient",
      "values": [ "FLTO", "CHED", "LETC", "GRBF",
                  "COTO", "JACK", "TMTO", "SLSA"]
    }
  ]
}

measurements下的数值并没有太大的用处,它代表了所有配料的总数。但是,如果想要知道有多少个taco使用墨西哥薄饼(flour tortilla)创建,可以将ingredient标签的值设置为FLTO:

$ curl localhost:8080/actuator/metrics/tacocloud?tag = ingredient:FLTO

{
  "name": "tacocloud",
  "measurements": [
    { "statistic": "COUNT", "value": 39 }
  ],
  "availableTags": []
}

现在,我们可以清楚地看到,有39个taco的配料中含有墨西哥薄饼。

创建自定义的端点

乍一看,你可能会认为Actuator端点不过是使用Spring MVC的控制器实现的,但是在第17章中你会发现,这些端点除了通过HTTP请求暴露之外,还会暴露成JMX MBean。因此,它们肯定不仅仅是控制器类的端点。

实际上,Actuator端点的定义与控制器有很大的差异。Actuator端点并不使用@Controller或@RestController注解来标注类,而是为类添加@Endpoint注解。

另外,Actuator端点的操作不使用HTTP方法命名的注解,如@GetMapping、@PostMapping、@DeleteMapping等。它们是通过为方法添加@ReadOperation、@WriteOperation和@DeleteOperation注解实现的。这些注解并没有指明任何的通信机制。实际上,它们允许Actuator与各种各样的通信机制协作,内置了对HTTP和JMX的支持。关于如何编写自定义的Actuator,参见程序清单15.7中的NotesEndpoint。

程序清单15.7 用来记笔记的自定义端点
package tacos.actuator;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
import org.springframework.stereotype.Component;

@Component
@Endpoint(id = "notes", enableByDefault = true)
public class NotesEndpoint {

  private List<Note> notes = new ArrayList<>();

  @ReadOperation
  public List<Note> notes() {
    return notes;
  }

  @WriteOperation
  public List<Note> addNote(String text) {
    notes.add(new Note(text));
    return notes;
  }

  @DeleteOperation
  public List<Note> deleteNote(int index) {
    if (index < notes.size()) {
      notes.remove(index);
    }
    return notes;
  }

  class Note {
    private Date time = new Date();
    private final String text;

    public Note(String text) {
      this.text = text;
    }

    public Date getTime() {
        return time;
    }

    public String getText() {
        return text;
    }
  }
}

这是一个非常简单的用于管理笔记的端点。我们可以通过写入操作提交笔记,通过读取操作阅读笔记列表,通过删除操作移除某个笔记。不得不承认,这个端点并不像Actuator的端点那样有用。但是考虑到“开箱即用”的Actuator端点提供了如此多的功能,设想一个自定义Actuator端点的实际样例有些困难。

不管怎么说,NotesEndpoint类使用了@Component注解,这样一来,它就会被Spring的组件扫描发现,并初始化为Spring应用上下文中的bean。但是,与我们的讨论关联最大的事情是,它还使用了@Endpoint注解,这使其成为一个ID为notes的Actuator端点。它默认就是启用的,所以我们不需要在management.web.endpoints.web.exposure.include配置属性中显式启用。

可以看到,NotesEndpoint提供了各种类型的操作。

  • notes()方法使用了@ReadOperation注解。当它被调用时,会返回一个可用笔记的列表。按照HTTP的术语,它会处理针对“/actuator/notes”的HTTP GET请求,并返回JSON格式的笔记列表。

  • addNote()方法使用了@WriteOperation注解。当它被调用的时候,会根据给定的文本创建一个新的笔记并将其添加到列表中。按照HTTP的术语,它处理POST请求,请求体中是一个包含text属性的JSON对象。最后,它会在响应中返回当前笔记列表的状态。

  • deleteNote()方法使用了@DeleteOperation注解。当它被调用的时候,将会根据给定的索引删除一条笔记。按照HTTP的术语,它会处理DELETE请求,其中索引是通过请求参数设置进来的。

为了看一下它的实际效果,我们可以使用curl测试新的端点。首先,使用两个单独的POST请求添加两条笔记:

$ curl localhost:8080/actuator/notes \
               -d'{"text":"Bring home milk"}' \
               -H"Content-type: application/json"
[{"time":"2020-06-08T13:50:45.085 + 0000","text":"Bring home milk"}]

$ curl localhost:8080/actuator/notes \
               -d'{"text":"Take dry cleaning"}' \
               -H"Content-type: application/json"
[{"time":"2021-07-03T12:39:13.058 + 0000","text":"Bring home milk"},
 {"time":"2021-07-03T12:39:16.012 + 0000","text":"Take dry cleaning"}]

可以看到,每次新增笔记,端点都会返回增加新内容之后的笔记列表。如果想要查看笔记列表,可以发送一个简单的GET请求:

$ curl localhost:8080/actuator/notes
[{"time":"2021-07-03T12:39:13.058 + 0000","text":"Bring home milk"},
 {"time":"2021-07-03T12:39:16.012 + 0000","text":"Take dry cleaning"}]

如果决定移除其中的某条笔记,可以发送一个DELETE请求,并将index作为请求的参数:

$ curl localhost:8080/actuator/notes?index = 1 -X DELETE
[{"time":" 2021-07-03T12:39:13.058 + 0000","text":"Bring home milk"}]

很重要的一点是,尽管此处只展现了如何使用HTTP与端点交互,但它还会暴露为MBean,使得我们可以使用任意的JMX客户端访问。如果只想暴露HTTP端点,可以使用@WebEndpoint注解而不是@Endpoint来标注端点类:

@Component
@WebEndpoint(id = "notes", enableByDefault = true)
public class NotesEndpoint {
  ...
}

类似地,如果只想暴露MBean端点,可以使用@JmxEndpoint注解标注。