16.3 自定义Actuator

16.3 自定义Actuator

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

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

16.3.1 为“/info”端点提供信息

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

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

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

创建自定义的Info贡献者

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

程序清单16.3 InfoContributor的自定义实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package tacos.tacos;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
import org.springframework.boot.actuate.info.Info.Builder;
@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();
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。然后,我们将这个数量放到一个Map中,以值为taco-stats的label将它传递到builder中。这样形成的“/info”端点将会包含这个数量,如下所示:

1
2
3
4
5
{
"taco-stats": {
"count": 44
}
}

我们可以看到,InfoContributor的实现可以以任何方式贡献信息。为属性添加“info.”前缀虽然简单,但是它们却只能是静态值。

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<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文件中:

1
2
3
springBoot {
buildInfo()
}

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

1
2
3
4
5
6
7
8
9
{
"build": {
"version": "0.0.16-SNAPSHOT",
"artifact": "ingredient-service",
"name": "ingredient-service",
"group": "sia5",
"time": "2018-06-04T00:24:04.373Z"
}
}

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

暴露Git提交信息

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

1
2
3
4
5
6
7
8
9
<build>
<plugins>
...
<plugin>
<groupId>pl.project13.maven</groupId>
<artifactId>git-commit-id-plugin</artifactId>
</plugin>
</plugins>
</build>

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

1
2
3
plugins {
id "com.gorylenko.gradle-git-properties" version "1.4.17"
}

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

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

1
2
3
4
5
6
7
8
9
10
{
"git": {
"commit": {
"time": "2018-06-02T18:10:58Z",
"id": "b5c104d"
},
"branch": "master"
},
...
}

这些信息非常确定地描述了项目构建时代码的状态。但是,我们还可以将management.info.git.mode属性设置为full:

1
2
3
4
management:
info:
git:
mode: full

这样我们就能得到项目构建时非常详尽的Git提交信息。程序清单16.4展现了完整Git信息的一个样例。

程序清单16.4 通过“/info”端点展现完整的Git信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
{
"git": {
"build": {
"host": "DarkSide.local",
"version": "0.0.16-SNAPSHOT",
"time": "2018-06-02T18:11:23Z",
"user": {
"name": "Craig Walls",
"email": "craig@habuma.com"
}
},
"branch": "master",
"commit": {
"message": {
"short": "Add Spring Boot Admin and Actuator",
"full": "Add Spring Boot Admin and Actuator"
},
"id": {
"describe": "b5c104d-dirty",
"abbrev": "b5c104d",
"describe-short": "b5c104d-dirty",
"full": "b5c104d1fcbe6c2b84965ea08a330595100fd44e"
},
"time": "2018-06-02T18:10:58Z",
"user": {
"email": "craig@habuma.com",
"name": "Craig Walls"
}
},
"closest": {
"tag": {
"name": "",
"commit": {
"count": ""
}
}
},
"dirty": "true",
"remote": {
"origin": {
"url": "Unknown"
}
},
"tags": ""
},
...
}

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

16.3.2 实现自定义的健康指示器

Spring Boot提供了多个内置的健康指示器,它们能够提供与Spring应用进行交互的通用外部系统的健康信息。有时候你可能会发现,你所使用的外部系统在SpringBoot的预料之外,Spring Boot也没有为它提供健康指示器。例如,你的应用可能与一个遗留的大型机应用进行交互,应用的健康状况可能会受到遗留系统健康状况的影响。为了创建自定义的健康指示器,我们需要做的就是创建一个实现了HealthIndicator接口的bean。

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

程序清单16.5 HealthIndicator的一个特殊实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package tacos.tacos;
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。

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

16.3.3 注册自定义的指标

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

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

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

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

程序清单16.6 TacoMetrics注册了关于taco配料的指标
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package tacos.tacos;
import java.util.List;
import
org.springframework.data.rest.core.event.AbstractRepositoryEventListener
;
import org.springframework.stereotype.Component;
import io.micrometer.core.instrument.MeterRegistry;
@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。它还扩展了AbstractRepositoryEventListener,这是Spring Data中的一个类,能够拦截repository事件。我们重写了onAfterCreate()方法,这样每当保存新的Taco对象时都会得到通知。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl localhost:8087/actuator/metrics/tacocloud
{
"name": "tacocloud",
"measurements": [ { "statistic": "COUNT", "value": 84 }
],
"availableTags": [
{
"tag": "ingredient",
"values": [ "FLTO", "CHED", "LETC", "GRBF",
"COTO", "JACK", "TMTO", "SLSA"]
}
]
}

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

1
2
3
4
5
6
7
8
$ curl localhost:8087/actuator/metrics/tacocloud?tag=ingredient:FLTO
{
"name": "tacocloud",
"measurements": [
{ "statistic": "COUNT", "value": 39 }
],
"availableTags": []
}

现在,我们可以清楚地看到,有39个taco是使用墨西哥薄饼作为其中的一道配料创建的。

16.3.4 创建自定义的端点

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

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

另外,它们不是使用HTTP方法命名的注解,如@GetMapping@PostMapping@DeleteMapping,Actuator端点的操作是通过为方法添加@ReadOperation@WriteOperation@DeleteOperation注解实现的。这些注解并没有指明任何的通信机制,实际上,这允许Actuator与各种各样的通信机制协作,内置了对HTTP和JMX的支持。

为了阐述如何编写自定义的Actuator,参见程序清单16.7中的NotesEndpoint。

程序清单16.7 用来记笔记的自定义端点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package tacos.ingredients;
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;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@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;
}
@RequiredArgsConstructor
private class Note {
@Getter
private Date time = new Date();
@Getter
private final String 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请求,添加两条笔记:

1
2
3
4
5
6
7
8
9
$ curl localhost:8080/actuator/notes \
-d'{"text":"Bring home milk"}' \
-H"Content-type: application/json"
[{"time":"2018-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":"2018-06-08T13:50:45.085+0000","text":"Bring home milk"},
{"time":"2018-06-08T13:50:48.021+0000","text":"Take dry cleaning"}]

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

1
2
3
$ curl localhost:8080/actuator/notes
[{"time":"2018-06-08T13:50:45.085+0000","text":"Bring home milk"},
{"time":"2018-06-08T13:50:48.021+0000","text":"Take dry cleaning"}]

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

1
2
$ curl localhost:8080/actuator/notes?index=1 -X DELETE
[{"time":"2018-06-08T13:50:45.085+0000","text":"Bring home milk"}]

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

1
2
3
4
5
@Component
@WebEndpoint(id="notes", enableByDefault=true)
public class NotesEndpoint {
...
}

类似的,如果你只想暴露MBean端点,那么可以使用@JmxEndpoint注解进行标注。