前提

Hystrix在2018年11月20日之后已经停止维护,最后一个提交记录是:Latest commit 3cb2158 on 20 Nov 2018,最后一个正式版本为1.5.18。鉴于目前所在公司的技术栈是Spring Cloud,熔断和降级组件主要用的还是Hystrix,这里就Hystrix的完整列表做一个分析记录,方便以后可以随时查询。本文主要参考:Hystrix Configuration。其中,命令配置是针对HystrixCommand,主要包括命令执行(execution)配置、命令降级(fallback)配置、熔断器(circuit breaker)配置、度量统计(metrics)配置和请求上下文配置。

通过micrometer实时监控线程池的各项指标

前提

最近的一个项目中涉及到文件上传和下载,使用到JUC的线程池ThreadPoolExecutor,在生产环境中出现了某些时刻线程池满负载运作,由于使用了CallerRunsPolicy拒绝策略,导致满负载情况下,应用接口调用无法响应,处于假死状态。考虑到之前用micrometer + prometheus + grafana搭建过监控体系,于是考虑使用micrometer做一次主动的线程池度量数据采集,最终可以相对实时地展示在grafana的面板中。

实践过程

下面通过真正的实战过程做一个仿真的例子用于复盘。

代码改造

首先我们要整理一下ThreadPoolExecutor中提供的度量数据项和micrometer对应的Tag的映射关系:

  • 线程池名称,Tag:thread.pool.name,这个很重要,用于区分各个线程池的数据,如果使用IOC容器管理,可以使用BeanName代替。
  • int getCorePoolSize():核心线程数,Tag:thread.pool.core.size
  • int getLargestPoolSize():历史峰值线程数,Tag:thread.pool.largest.size
  • int getMaximumPoolSize():最大线程数(线程池线程容量),Tag:thread.pool.max.size
  • int getActiveCount():当前活跃线程数,Tag:thread.pool.active.size
  • int getPoolSize():当前线程池中运行的线程总数(包括核心线程和非核心线程),Tag:thread.pool.thread.count
  • 当前任务队列中积压任务的总数,Tag:thread.pool.queue.size,这个需要动态计算得出。

接着编写具体的代码,实现的功能如下:

  • 1、建立一个ThreadPoolExecutor实例,核心线程和最大线程数为10,任务队列长度为10,拒绝策略为AbortPolicy
  • 2、提供两个方法,分别使用线程池实例模拟短时间耗时的任务和长时间耗时的任务。
  • 3、提供一个方法用于清空线程池实例中的任务队列。
  • 4、提供一个单线程的调度线程池用于定时收集ThreadPoolExecutor实例中上面列出的度量项,保存到micrometer内存态的收集器中。

由于这些统计的值都会跟随时间发生波动性变更,可以考虑选用Gauge类型的Meter进行记录。

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// ThreadPoolMonitor
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

/**
* @author throwable
* @version v1.0
* @description
* @since 2019/4/7 21:02
*/
@Service
public class ThreadPoolMonitor implements InitializingBean {

private static final String EXECUTOR_NAME = "ThreadPoolMonitorSample";
private static final Iterable<Tag> TAG = Collections.singletonList(Tag.of("thread.pool.name", EXECUTOR_NAME));
private final ScheduledExecutorService scheduledExecutor = Executors.newSingleThreadScheduledExecutor();

private final ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, 0, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10), new ThreadFactory() {

private final AtomicInteger counter = new AtomicInteger();

@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setDaemon(true);
thread.setName("thread-pool-" + counter.getAndIncrement());
return thread;
}
}, new ThreadPoolExecutor.AbortPolicy());


private Runnable monitor = () -> {
//这里需要捕获异常,尽管实际上不会产生异常,但是必须预防异常导致调度线程池线程失效的问题
try {
Metrics.gauge("thread.pool.core.size", TAG, executor, ThreadPoolExecutor::getCorePoolSize);
Metrics.gauge("thread.pool.largest.size", TAG, executor, ThreadPoolExecutor::getLargestPoolSize);
Metrics.gauge("thread.pool.max.size", TAG, executor, ThreadPoolExecutor::getMaximumPoolSize);
Metrics.gauge("thread.pool.active.size", TAG, executor, ThreadPoolExecutor::getActiveCount);
Metrics.gauge("thread.pool.thread.count", TAG, executor, ThreadPoolExecutor::getPoolSize);
// 注意如果阻塞队列使用无界队列这里不能直接取size
Metrics.gauge("thread.pool.queue.size", TAG, executor, e -> e.getQueue().size());
} catch (Exception e) {
//ignore
}
};

@Override
public void afterPropertiesSet() throws Exception {
// 每5秒执行一次
scheduledExecutor.scheduleWithFixedDelay(monitor, 0, 5, TimeUnit.SECONDS);
}

public void shortTimeWork() {
executor.execute(() -> {
try {
// 5秒
Thread.sleep(5000);
} catch (InterruptedException e) {
//ignore
}
});
}

public void longTimeWork() {
executor.execute(() -> {
try {
// 500秒
Thread.sleep(5000 * 100);
} catch (InterruptedException e) {
//ignore
}
});
}

public void clearTaskQueue() {
executor.getQueue().clear();
}
}

//ThreadPoolMonitorController
import club.throwable.smp.service.ThreadPoolMonitor;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* @author throwable
* @version v1.0
* @description
* @since 2019/4/7 21:20
*/
@RequiredArgsConstructor
@RestController
public class ThreadPoolMonitorController {

private final ThreadPoolMonitor threadPoolMonitor;

@GetMapping(value = "/shortTimeWork")
public ResponseEntity<String> shortTimeWork() {
threadPoolMonitor.shortTimeWork();
return ResponseEntity.ok("success");
}

@GetMapping(value = "/longTimeWork")
public ResponseEntity<String> longTimeWork() {
threadPoolMonitor.longTimeWork();
return ResponseEntity.ok("success");
}

@GetMapping(value = "/clearTaskQueue")
public ResponseEntity<String> clearTaskQueue() {
threadPoolMonitor.clearTaskQueue();
return ResponseEntity.ok("success");
}
}

配置如下:

1
2
3
4
5
6
7
8
9
10
server:
port: 9091
management:
server:
port: 9091
endpoints:
web:
exposure:
include: '*'
base-path: /management

prometheus的调度Job也可以适当调高频率,这里默认是15秒拉取一次/prometheus端点,也就是会每次提交3个收集周期的数据。项目启动之后,可以尝试调用/management/prometheus查看端点提交的数据:

j-m-t-p-1.png

因为ThreadPoolMonitorSample是我们自定义命名的Tag,看到相关字样说明数据收集是正常的。如果prometheus的Job没有配置错误,在本地的spring-boot项目起来后,可以查下prometheus的后台:

j-m-t-p-2.png

j-m-t-p-3.png

OK,完美,可以进行下一步。

grafana面板配置

确保JVM应用和prometheus的调度Job是正常的情况下,接下来重要的一步就是配置grafana面板。如果暂时不想认真学习一下prometheus的PSQL的话,可以从prometheus后台的/graph面板直接搜索对应的样本表达式拷贝进去grafana配置中就行,当然最好还是去看下prometheus的文档系统学习一下怎么编写PSQL。

  • 基本配置:

j-m-t-p-4.png

  • 可视化配置,把右边的标签勾选,宽度尽量调大点:

j-m-t-p-5.png

  • 查询配置,这个是最重要的,最终图表就是靠查询配置展示的:

j-m-t-p-6.png

查询配置具体如下:

  • A:thread_pool_active_size,Legend:-线程池活跃线程数。
  • B:thread_pool_largest_size,Legend:-线程池历史峰值线程数。
  • C:thread_pool_max_size,Legend:-线程池容量。
  • D:thread_pool_core_size,Legend:-线程池核心线程数。
  • E:thread_pool_thread_count,Legend:-线程池运行中的线程数。
  • F:thread_pool_queue_size,Legend:-线程池积压任务数。

最终效果

多调用几次例子中提供的几个接口,就能得到一个监控线程池呈现的图表:

j-m-t-p-7.png

小结

针对线程池ThreadPoolExecutor的各项数据进行监控,有利于及时发现使用线程池的接口的异常,如果想要快速恢复,最有效的途径是:清空线程池中任务队列中积压的任务。具体的做法是:可以把ThreadPoolExecutor委托到IOC容器管理,并且把ThreadPoolExecutor任务队列清空的方法暴露成一个REST端点即可。像HTTP客户端的连接池如Apache-Http-Client或者OkHttp等的监控,可以用类似的方式实现,数据收集的时候可能由于加锁等原因会有少量的性能损耗,不过这些都是可以忽略的,如果真的怕有性能影响,可以尝试用反射API直接获取ThreadPoolExecutor实例内部的属性值,这样就可以避免加锁的性能损耗

(本文完 c-2-d 20190414)

zuul源码分析-探究原生zuul的工作原理

前提

最近在项目中使用了SpringCloud,基于Zuul搭建了一个提供加解密、鉴权等功能的网关服务。鉴于之前没怎么使用过Zuul,于是顺便仔细阅读了它的源码。实际上,Zuul原来提供的功能是很单一的:通过一个统一的Servlet入口(ZuulServlet,或者Filter入口,使用ZuulServletFilter)拦截所有的请求,然后通过内建的com.netflix.zuul.IZuulFilter链对请求做拦截和过滤处理。ZuulFilterjavax.servlet.Filter的原理相似,但是它们本质并不相同。javax.servlet.Filter在Web应用中是独立的组件,ZuulFilterZuulServlet处理请求时候调用的,后面会详细分析。

源码环境准备

Zuul的项目地址是https://github.com/Netflix/zuul,它是著名的"开源框架提供商"Netflix的作品,项目的目的是:Zuul是一个网关服务,提供动态路由、监视、弹性、安全性等。在SpringCloud中引入了zuul,配合Netflix的另一个负载均衡框架Ribbon和Netflix的另一个提供服务发现与注册框架Eureka,可以实现服务的动态路由。值得注意的是,zuul在2.x甚至3.x的分支中已经引入了netty,框架的复杂性大大提高。但是当前的SpringCloud体系并没有升级zuul的版本,目前使用的是zuul1.x的最高版本1.3.1。

z-s-c-1

因此我们需要阅读它的源码的时候可以选择这个发布版本。值得注意的是,由于这些版本的发布时间已经比较久,有部分插件或者依赖包可能找不到,笔者在构建zuul1.3.1的源码的时候发现这几个问题:

  • 1、nebula.netflixoss插件的旧版本已经不再支持,所有build.gradle文件中的nebula.netflixoss插件的版本修改为5.2.0。
  • 2、2017年的时候Gradle支持的版本是2.x,笔者这里选择了gradle-2.14,选择高版本的Gradle有可能在构建项目的时候出现jetty插件不支持。
  • 3、Jdk最好使用1.8,Gradle构建文件中的sourceCompatibility、targetCompatibility、languageLevel等配置全改为1.8。

另外,如果使用IDEA进行构建,注意配置项目的Jdk和Java环境,所有配置改为Jdk1.8,Gradle构建成功后如下:

z-s-c-2

zuul-1.3.1中提供了一个Web应用的Sample项目,我们直接运行zuul-simple-webapp的Gradle配置中的Tomcat插件即可启动项目,开始Debug之旅:

z-s-c-3

源码分析

ZuulFilter的加载

从Zuul的源码来看,ZuulFilter的加载模式可能跟我们想象的大有不同,Zuul设计的初衷是ZuulFilter是存放在Groovy文件中,可以实现基于最后修改时间进行热加载。我们先看看Zuul核心类之一com.netflix.zuul.filters.FilterRegistry(Filter的注册中心,实际上是ZuulFilter的全局缓存):

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
public class FilterRegistry {

// 饿汉式单例,确保全局只有一个ZuulFilter的缓存
private static final FilterRegistry INSTANCE = new FilterRegistry();
public static final FilterRegistry instance() {
return INSTANCE;
}

//缓存字符串到ZuulFilter实例的映射关系,如果是从文件加载,字符串key的格式是:文件绝对路径 + 文件名,当然也可以自实现
private final ConcurrentHashMap<String, ZuulFilter> filters = new ConcurrentHashMap<String, ZuulFilter>();

private FilterRegistry() {
}

public ZuulFilter remove(String key) {
return this.filters.remove(key);
}

public ZuulFilter get(String key) {
return this.filters.get(key);
}

public void put(String key, ZuulFilter filter) {
this.filters.putIfAbsent(key, filter);
}

public int size() {
return this.filters.size();
}

public Collection<ZuulFilter> getAllFilters() {
return this.filters.values();
}

}

实际上Zuul使用了简单粗暴的方式(直接使用ConcurrentHashMap)缓存了ZuulFilter,这些缓存除非主动调用remove方法,否则不会自动清理。Zuul提供默认的动态代码编译器,接口是DynamicCodeCompiler,目的是把代码编译为Java的类,默认实现是GroovyCompiler,功能就是把Groovy代码编译为Java类。还有一个比较重要的工厂类接口是FilterFactory,它定义了ZuulFilter类生成ZuulFilter实例的逻辑,默认实现是DefaultFilterFactory,实际上就是利用Class#newInstance()反射生成ZuulFilter实例。接着,我们可以进行分析FilterLoader的源码,这个类的作用就是加载文件中的ZuulFilter实例:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
public class FilterLoader {
//静态final实例,注意到访问权限是包许可,实际上就是饿汉式单例
final static FilterLoader INSTANCE = new FilterLoader();

private static final Logger LOG = LoggerFactory.getLogger(FilterLoader.class);

//缓存Filter名称(主要是从文件加载,名称为绝对路径 + 文件名的形式)->Filter最后修改时间戳的映射
private final ConcurrentHashMap<String, Long> filterClassLastModified = new ConcurrentHashMap<String, Long>();
//缓存Filter名字->Filter代码的映射,实际上这个Map只使用到get方法进行存在性判断,一直是一个空的结构
private final ConcurrentHashMap<String, String> filterClassCode = new ConcurrentHashMap<String, String>();
//缓存Filter名字->Filter名字的映射,用于存在性判断
private final ConcurrentHashMap<String, String> filterCheck = new ConcurrentHashMap<String, String>();
//缓存Filter类型名称->List<ZuulFilter>的映射
private final ConcurrentHashMap<String, List<ZuulFilter>> hashFiltersByType = new ConcurrentHashMap<String, List<ZuulFilter>>();

//前面提到的ZuulFilter全局缓存的单例
private FilterRegistry filterRegistry = FilterRegistry.instance();
//动态代码编译器实例,Zuul提供的默认实现是GroovyCompiler
static DynamicCodeCompiler COMPILER;
//ZuulFilter的工厂类
static FilterFactory FILTER_FACTORY = new DefaultFilterFactory();
//下面三个方法说明DynamicCodeCompiler、FilterRegistry、FilterFactory可以被覆盖
public void setCompiler(DynamicCodeCompiler compiler) {
COMPILER = compiler;
}

public void setFilterRegistry(FilterRegistry r) {
this.filterRegistry = r;
}

public void setFilterFactory(FilterFactory factory) {
FILTER_FACTORY = factory;
}
//饿汉式单例获取自身实例
public static FilterLoader getInstance() {
return INSTANCE;
}
//返回所有缓存的ZuulFilter实例的总数量
public int filterInstanceMapSize() {
return filterRegistry.size();
}

//通过ZuulFilter的类代码和Filter名称获取ZuulFilter实例
public ZuulFilter getFilter(String sCode, String sName) throws Exception {
//检查filterCheck是否存在相同名字的Filter,如果存在说明已经加载过
if (filterCheck.get(sName) == null) {
//filterCheck中放入Filter名称
filterCheck.putIfAbsent(sName, sName);
//filterClassCode中不存在加载过的Filter名称对应的代码
if (!sCode.equals(filterClassCode.get(sName))) {
LOG.info("reloading code " + sName);
//从全局缓存中移除对应的Filter
filterRegistry.remove(sName);
}
}
ZuulFilter filter = filterRegistry.get(sName);
//如果全局缓存中不存在对应的Filter,就使用DynamicCodeCompiler加载代码,使用FilterFactory实例化ZuulFilter
//注意加载的ZuulFilter类不能是抽象的,必须是继承了ZuulFilter的子类
if (filter == null) {
Class clazz = COMPILER.compile(sCode, sName);
if (!Modifier.isAbstract(clazz.getModifiers())) {
filter = (ZuulFilter) FILTER_FACTORY.newInstance(clazz);
}
}
return filter;
}

//通过文件加加载ZuulFilter
public boolean putFilter(File file) throws Exception {
//Filter名称为文件的绝对路径+文件名(这里其实绝对路径已经包含文件名,这里再加文件名的目的不明确)
String sName = file.getAbsolutePath() + file.getName();
//如果文件被修改过则从全局缓存从移除对应的Filter以便重新加载
if (filterClassLastModified.get(sName) != null && (file.lastModified() != filterClassLastModified.get(sName))) {
LOG.debug("reloading filter " + sName);
filterRegistry.remove(sName);
}
//下面的逻辑和上一个方法类似
ZuulFilter filter = filterRegistry.get(sName);
if (filter == null) {
Class clazz = COMPILER.compile(file);
if (!Modifier.isAbstract(clazz.getModifiers())) {
filter = (ZuulFilter) FILTER_FACTORY.newInstance(clazz);
List<ZuulFilter> list = hashFiltersByType.get(filter.filterType());
//这里说明了一旦文件有修改,hashFiltersByType中对应的当前文件加载出来的Filter类型的缓存要移除,原因见下一个方法
if (list != null) {
hashFiltersByType.remove(filter.filterType()); //rebuild this list
}
filterRegistry.put(file.getAbsolutePath() + file.getName(), filter);
filterClassLastModified.put(sName, file.lastModified());
return true;
}
}
return false;
}
//通过Filter类型获取同类型的所有ZuulFilter
public List<ZuulFilter> getFiltersByType(String filterType) {
List<ZuulFilter> list = hashFiltersByType.get(filterType);
if (list != null) return list;
list = new ArrayList<ZuulFilter>();
//如果hashFiltersByType缓存被移除,这里从全局缓存中加载所有的ZuulFilter,按照指定类型构建一个新的列表
Collection<ZuulFilter> filters = filterRegistry.getAllFilters();
for (Iterator<ZuulFilter> iterator = filters.iterator(); iterator.hasNext(); ) {
ZuulFilter filter = iterator.next();
if (filter.filterType().equals(filterType)) {
list.add(filter);
}
}
//注意这里会进行排序,是基于filterOrder
Collections.sort(list); // sort by priority
//这里总是putIfAbsent,这就是为什么上个方法可以放心地在修改的情况下移除指定Filter类型中的全部缓存实例的原因
hashFiltersByType.putIfAbsent(filterType, list);
return list;
}
}

上面的几个方法和缓存容器都比较简单,这里实际上有加载和存放动作的方法只有putFilter,这个方法正是Filter文件管理器FilterFileManager依赖的,接着看FilterFileManager的源码:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
public class FilterFileManager {

private static final Logger LOG = LoggerFactory.getLogger(FilterFileManager.class);

String[] aDirectories;
int pollingIntervalSeconds;
Thread poller;
boolean bRunning = true;
//文件名过滤器,Zuul中的默认实现是GroovyFileFilter,只接受.groovy后缀的文件
static FilenameFilter FILENAME_FILTER;

static FilterFileManager INSTANCE;

private FilterFileManager() {
}

public static void setFilenameFilter(FilenameFilter filter) {
FILENAME_FILTER = filter;
}
//init方法是核心静态方法,它具备了配置,预处理和激活后台轮询线程的功能
public static void init(int pollingIntervalSeconds, String... directories) throws Exception, IllegalAccessException, InstantiationException{
if (INSTANCE == null) INSTANCE = new FilterFileManager();
INSTANCE.aDirectories = directories;
INSTANCE.pollingIntervalSeconds = pollingIntervalSeconds;
INSTANCE.manageFiles();
INSTANCE.startPoller();
}

public static FilterFileManager getInstance() {
return INSTANCE;
}

public static void shutdown() {
INSTANCE.stopPoller();
}

void stopPoller() {
bRunning = false;
}
//启动后台轮询守护线程,每休眠pollingIntervalSeconds秒则进行一次文件扫描尝试更新Filter
void startPoller() {
poller = new Thread("GroovyFilterFileManagerPoller") {
public void run() {
while (bRunning) {
try {
sleep(pollingIntervalSeconds * 1000);
//预处理文件,实际上是ZuulFilter的预加载
manageFiles();
} catch (Exception e) {
e.printStackTrace();
}
}
}
};
//设置为守护线程
poller.setDaemon(true);
poller.start();
}
//根据指定目录路径获取目录,主要需要转换为ClassPath
public File getDirectory(String sPath) {
File directory = new File(sPath);
if (!directory.isDirectory()) {
URL resource = FilterFileManager.class.getClassLoader().getResource(sPath);
try {
directory = new File(resource.toURI());
} catch (Exception e) {
LOG.error("Error accessing directory in classloader. path=" + sPath, e);
}
if (!directory.isDirectory()) {
throw new RuntimeException(directory.getAbsolutePath() + " is not a valid directory");
}
}
return directory;
}

//遍历配置目录,获取所有配置目录下的所有满足FilenameFilter过滤条件的文件
List<File> getFiles() {
List<File> list = new ArrayList<File>();
for (String sDirectory : aDirectories) {
if (sDirectory != null) {
File directory = getDirectory(sDirectory);
File[] aFiles = directory.listFiles(FILENAME_FILTER);
if (aFiles != null) {
list.addAll(Arrays.asList(aFiles));
}
}
}
return list;
}
//遍历指定文件列表,调用FilterLoader单例中的putFilter
void processGroovyFiles(List<File> aFiles) throws Exception, InstantiationException, IllegalAccessException {
for (File file : aFiles) {
FilterLoader.getInstance().putFilter(file);
}
}
//获取指定目录下的所有文件,调用processGroovyFiles,个人认为这两个方法没必要做单独封装
void manageFiles() throws Exception, IllegalAccessException, InstantiationException {
List<File> aFiles = getFiles();
processGroovyFiles(aFiles);
}

分析完FilterFileManager源码之后,Zuul中基于文件加载ZuulFilter的逻辑已经十分清晰:后台启动一个守护线程,定时轮询指定文件夹里面的文件,如果文件存在变更,则尝试更新指定的ZuulFilter缓存,FilterFileManager的init方法调用的时候在启动后台线程之前会进行一次预加载。

RequestContext

在分析ZuulFilter的使用之前,有必要先了解Zuul中的请求上下文对象RequestContext。首先要有一个共识:每一个新的请求都是由一个独立的线程处理(这个线程是Tomcat里面起的线程),换言之,请求的所有参数(Http报文信息解析出来的内容,如请求头、请求体等等)总是绑定在处理请求的线程中。RequestContext的设计就是简单直接有效,它继承于ConcurrentHashMap<String, Object>,所以参数可以直接设置在RequestContext中,Zuul没有设计一个类似于枚举的类控制RequestContext的可选参数,因此里面的设置值和提取值的方法都是硬编码的,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public HttpServletRequest getRequest() {
return (HttpServletRequest) get("request");
}

public void setRequest(HttpServletRequest request) {
put("request", request);
}

public HttpServletResponse getResponse() {
return (HttpServletResponse) get("response");
}

public void setResponse(HttpServletResponse response) {
set("response", response);
}
...

看起来很暴力并且不怎么优雅,但是实际上是高效的。RequestContext一般使用静态方法RequestContext#getCurrentContext()进行初始化,我们分析一下它的初始化流程:

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
//保存RequestContext自身类型
protected static Class<? extends RequestContext> contextClass = RequestContext.class;
//静态对象
private static RequestContext testContext = null;
//静态final修饰的ThreadLocal实例,用于存放所有的RequestContext,每个RequestContext都会绑定在自身请求的处理线程中
//注意这里的ThreadLocal实例的initialValue()方法,当ThreadLocal的get()方法返回null的时候总是会调用initialValue()方法
protected static final ThreadLocal<? extends RequestContext> threadLocal = new ThreadLocal<RequestContext>() {
@Override
protected RequestContext initialValue() {
try {
return contextClass.newInstance();
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
};


public RequestContext() {
super();
}

public static RequestContext getCurrentContext() {
//这里混杂了测试的代码,暂时忽略
if (testContext != null) return testContext;
//当ThreadLocal的get()方法返回null的时候总是会调用initialValue()方法,所以这里是"无则新建RequestContext"的逻辑
RequestContext context = threadLocal.get();
return context;
}

注意上面的ThreadLocal覆盖了初始化方法initialValue()ThreadLocal的初始化方法总是在ThreadLocal#get()方法返回null的时候调用,实际上静态方法RequestContext#getCurrentContext()的作用就是:如果ThreadLocal中已经绑定了RequestContext静态实例就直接获取绑定在线程中的RequestContext实例,否则新建一个RequestContext实例存放在ThreadLocal(绑定到当前的请求线程中)。了解这一点后面分析ZuulServletFilter和ZuulServlet的时候就很简单了。

ZuulFilter

抽象类com.netflix.zuul.ZuulFilter是Zuul里面的核心组件,它是用户扩展Zuul行为的组件,用户可以实现不同类型的ZuulFilter、定义它们的执行顺序、实现它们的执行方法达到定制化的目的,SpringCloud的netflix-zuul就是一个很好的实现包。ZuulFilter实现了IZuulFilter接口,我们先看这个接口的定义:

1
2
3
4
5
6
public interface IZuulFilter {

boolean shouldFilter();

Object run() throws ZuulException;
}

很简单,shouldFilter()方法决定是否需要执行(也就是执行时机由使用者扩展,甚至可以禁用),而run()方法决定执行的逻辑。接着看ZuulFilter的源码:

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
48
49
50
51
52
53
54
55
56
public abstract class ZuulFilter implements IZuulFilter, Comparable<ZuulFilter> {
//netflix的配置组件,实际上就是基于配置文件提取的指定key的值
private final AtomicReference<DynamicBooleanProperty> filterDisabledRef = new AtomicReference<>();

//定义Filter的类型
abstract public String filterType();

//定义当前Filter实例执行的顺序
abstract public int filterOrder();

//是否静态的Filter,静态的Filter是无状态的
public boolean isStaticFilter() {
return true;
}

//禁用当前Filter的配置属性的Key名称
//Key=zuul.${全类名}.${filterType}.disable
public String disablePropertyName() {
return "zuul." + this.getClass().getSimpleName() + "." + filterType() + ".disable";
}

//判断当前的Filter是否禁用,通过disablePropertyName方法从配置中读取,默认是不禁用,也就是启用
public boolean isFilterDisabled() {
filterDisabledRef.compareAndSet(null, DynamicPropertyFactory.getInstance().getBooleanProperty(disablePropertyName(), false));
return filterDisabledRef.get().get();
}

//这个是核心方法,执行Filter,如果Filter不是禁用、并且满足执行时机则调用run方法,返回执行结果,记录执行轨迹
public ZuulFilterResult runFilter() {
ZuulFilterResult zr = new ZuulFilterResult();
if (!isFilterDisabled()) {
if (shouldFilter()) {
Tracer t = TracerFactory.instance().startMicroTracer("ZUUL::" + this.getClass().getSimpleName());
try {
Object res = run();
zr = new ZuulFilterResult(res, ExecutionStatus.SUCCESS);
} catch (Throwable e) {
t.setName("ZUUL::" + this.getClass().getSimpleName() + " failed");
zr = new ZuulFilterResult(ExecutionStatus.FAILED);
//注意这里只保存异常的实例,即使执行抛出异常
zr.setException(e);
} finally {
t.stopAndLog();
}
} else {
zr = new ZuulFilterResult(ExecutionStatus.SKIPPED);
}
}
return zr;
}

//实现Comparable,基于filterOrder升序排序,也就是filterOrder越大,执行优先度越低
public int compareTo(ZuulFilter filter) {
return Integer.compare(this.filterOrder(), filter.filterOrder());
}
}

这里注意几个地方,第一个是filterOrder()方法和compareTo(ZuulFilter filter)方法,子类实现ZuulFilter时候,filterOrder()方法返回值越大,或者说Filter的顺序系数越大,ZuulFilter执行的优先度越低。第二个地方是可以通过zuul.${全类名}.${filterType}.disable=false通过类名和Filter类型禁用对应的Filter。第三个值得注意的地方是Zuul中定义了四种类型的ZuulFilter,后面分析ZuulRunner的时候再详细展开。ZuulFilter实际上就是使用者扩展的核心组件,通过实现ZuulFilter的方法可以在一个请求处理链中的特定位置执行特定的定制化逻辑。第四个值得注意的地方是runFilter()方法执行不会抛出异常,如果出现异常,Throwable实例会保存在ZuulFilterResult对象中返回到外层方法,如果正常执行,则直接返回runFilter()方法的结果。

FilterProcessor

前面花大量功夫分析完ZuulFilter基于Groovy文件的加载机制(在SpringCloud体系中并没有使用此策略,因此,我们持了解的态度即可)以及RequestContext的设计,接着我们分析FilterProcessor去了解如何使用加载好的缓存中的ZuulFilter。我们先看FilterProcessor的基本属性:

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
public class FilterProcessor {

static FilterProcessor INSTANCE = new FilterProcessor();
protected static final Logger logger = LoggerFactory.getLogger(FilterProcessor.class);

private FilterUsageNotifier usageNotifier;


public FilterProcessor() {
usageNotifier = new BasicFilterUsageNotifier();
}

public static FilterProcessor getInstance() {
return INSTANCE;
}

public static void setProcessor(FilterProcessor processor) {
INSTANCE = processor;
}

public void setFilterUsageNotifier(FilterUsageNotifier notifier) {
this.usageNotifier = notifier;
}
...
}

像之前分析的几个类一样,FilterProcessor设计为单例,提供可以覆盖单例实例的方法。需要注意的一点是属性usageNotifier是FilterUsageNotifier类型,FilterUsageNotifier接口的默认实现是BasicFilterUsageNotifier(FilterProcessor的一个静态内部类),BasicFilterUsageNotifier依赖于Netflix的一个工具包servo-core,提供基于内存态的计数器统计每种ZuulFilter的每一次调用的状态ExecutionStatus。枚举ExecutionStatus的可选值如下:

  • 1、SUCCESS,代表该Filter处理成功,值为1。
  • 2、SKIPPED,代表该Filter跳过处理,值为-1。
  • 3、DISABLED,代表该Filter禁用,值为-2。
  • 4、SUCCESS,代表该FAILED处理出现异常,值为-3。

当然,使用者也可以覆盖usageNotifier属性。接着我们看FilterProcessor中真正调用ZuulFilter实例的核心方法:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
//指定Filter类型执行该类型下的所有ZuulFilter
public Object runFilters(String sType) throws Throwable {
//尝试打印Debug日志
if (RequestContext.getCurrentContext().debugRouting()) {
Debug.addRoutingDebug("Invoking {" + sType + "} type filters");
}
boolean bResult = false;
//获取所有指定类型的ZuulFilter
List<ZuulFilter> list = FilterLoader.getInstance().getFiltersByType(sType);
if (list != null) {
for (int i = 0; i < list.size(); i++) {
ZuulFilter zuulFilter = list.get(i);
Object result = processZuulFilter(zuulFilter);
//如果处理结果是Boolean类型尝试做或操作,其他类型结果忽略
if (result != null && result instanceof Boolean) {
bResult |= ((Boolean) result);
}
}
}
return bResult;
}
//执行ZuulFilter,这个就是ZuulFilter执行逻辑
public Object processZuulFilter(ZuulFilter filter) throws ZuulException {
RequestContext ctx = RequestContext.getCurrentContext();
boolean bDebug = ctx.debugRouting();
final String metricPrefix = "zuul.filter-";
long execTime = 0;
String filterName = "";
try {
long ltime = System.currentTimeMillis();
filterName = filter.getClass().getSimpleName();
RequestContext copy = null;
Object o = null;
Throwable t = null;
if (bDebug) {
Debug.addRoutingDebug("Filter " + filter.filterType() + " " + filter.filterOrder() + " " + filterName);
copy = ctx.copy();
}
//简单调用ZuulFilter的runFilter方法
ZuulFilterResult result = filter.runFilter();
ExecutionStatus s = result.getStatus();
execTime = System.currentTimeMillis() - ltime;
switch (s) {
case FAILED:
t = result.getException();
//记录调用链中当前Filter的名称,执行结果状态和执行时间
ctx.addFilterExecutionSummary(filterName, ExecutionStatus.FAILED.name(), execTime);
break;
case SUCCESS:
o = result.getResult();
//记录调用链中当前Filter的名称,执行结果状态和执行时间
ctx.addFilterExecutionSummary(filterName, ExecutionStatus.SUCCESS.name(), execTime);
if (bDebug) {
Debug.addRoutingDebug("Filter {" + filterName + " TYPE:" + filter.filterType() + " ORDER:" + filter.filterOrder() + "} Execution time = " + execTime + "ms");
Debug.compareContextState(filterName, copy);
}
break;
default:
break;
}

if (t != null) throw t;
//这里做计数器的统计
usageNotifier.notify(filter, s);
return o;

} catch (Throwable e) {
if (bDebug) {
Debug.addRoutingDebug("Running Filter failed " + filterName + " type:" + filter.filterType() + " order:" + filter.filterOrder() + " " + e.getMessage());
}
//这里做计数器的统计
usageNotifier.notify(filter, ExecutionStatus.FAILED);
if (e instanceof ZuulException) {
throw (ZuulException) e;
} else {
ZuulException ex = new ZuulException(e, "Filter threw Exception", 500, filter.filterType() + ":" + filterName);
//记录调用链中当前Filter的名称,执行结果状态和执行时间
ctx.addFilterExecutionSummary(filterName, ExecutionStatus.FAILED.name(), execTime);
throw ex;
}
}
}

上面介绍了FilterProcessor中的processZuulFilter(ZuulFilter filter)方法主要提供ZuulFilter执行的一些度量相关记录(例如Filter执行耗时摘要,会形成一个链,记录在一个字符串中)和ZuulFilter的执行方法,ZuulFilter执行结果可能是成功或者异常,前面提到过,如果抛出异常Throwable实例会保存在ZuulFilterResult中,在processZuulFilter(ZuulFilter filter)发现ZuulFilterResult中的Throwable实例不为null则直接抛出,否则返回ZuulFilter正常执行的结果。另外,FilterProcessor中通过指定Filter类型执行所有对应类型的ZuulFilterrunFilters(String sType)方法,我们知道了runFilters(String sType)方法如果处理结果是Boolean类型尝试做或操作,其他类型结果忽略,可以理解为此方法的返回值是没有很大意义的。参考SpringCloud里面对ZuulFilter的返回值处理一般是直接塞进去当前线程绑定的RequestContext中,选择特定的ZuulFilter子类对前面的ZuulFilter产生的结果进行处理。FilterProcessor基于runFilters(String sType)方法提供了其他指定filterType的方法:

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
public void postRoute() throws ZuulException {
try {
runFilters("post");
} catch (ZuulException e) {
throw e;
} catch (Throwable e) {
throw new ZuulException(e, 500, "UNCAUGHT_EXCEPTION_IN_POST_FILTER_" + e.getClass().getName());
}
}

public void preRoute() throws ZuulException {
try {
runFilters("pre");
} catch (ZuulException e) {
throw e;
} catch (Throwable e) {
throw new ZuulException(e, 500, "UNCAUGHT_EXCEPTION_IN_PRE_FILTER_" + e.getClass().getName());
}
}

public void error() {
try {
runFilters("error");
} catch (Throwable e) {
logger.error(e.getMessage(), e);
}
}

public void route() throws ZuulException {
try {
runFilters("route");
} catch (ZuulException e) {
throw e;
} catch (Throwable e) {
throw new ZuulException(e, 500, "UNCAUGHT_EXCEPTION_IN_ROUTE_FILTER_" + e.getClass().getName());
}
}

上面提供的方法很简单,无法是指定参数为post、pre、error、routerunFilters(String sType)方法进行调用,至于这些FilterType的执行位置见下一个小节的分析。

ZuulServletFilter和ZuulServlet

Zuul本来就是设计为Servlet规范组件的一个类库,ZuulServlet就是javax.servlet.http.HttpServlet的实现类,而ZuulServletFilterjavax.servlet.Filter的实现类。这两个类都依赖到ZuulRunner完成ZuulFilter的调用,它们的实现逻辑是完全一致的,我们只需要看其中一个类的实现,这里挑选ZuulServlet

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public class ZuulServlet extends HttpServlet {

private static final long serialVersionUID = -3374242278843351500L;
private ZuulRunner zuulRunner;

@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
String bufferReqsStr = config.getInitParameter("buffer-requests");
boolean bufferReqs = bufferReqsStr != null && bufferReqsStr.equals("true") ? true : false;
zuulRunner = new ZuulRunner(bufferReqs);
}

@Override
public void service(javax.servlet.ServletRequest servletRequest, javax.servlet.ServletResponse servletResponse) throws ServletException, IOException {
try {
//实际上委托到ZuulRunner的init方法
init((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse);
//初始化RequestContext实例
RequestContext context = RequestContext.getCurrentContext();
//设置RequestContext中zuulEngineRan=true
context.setZuulEngineRan();
try {
preRoute();
} catch (ZuulException e) {
error(e);
postRoute();
return;
}
try {
route();
} catch (ZuulException e) {
error(e);
postRoute();
return;
}
try {
postRoute();
} catch (ZuulException e) {
error(e);
return;
}

} catch (Throwable e) {
error(new ZuulException(e, 500, "UNHANDLED_EXCEPTION_" + e.getClass().getName()));
} finally {
RequestContext.getCurrentContext().unset();
}
}

void postRoute() throws ZuulException {
zuulRunner.postRoute();
}

void route() throws ZuulException {
zuulRunner.route();
}

void preRoute() throws ZuulException {
zuulRunner.preRoute();
}

void init(HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
zuulRunner.init(servletRequest, servletResponse);
}
//这里会先设置RequestContext实例中的throwable属性为执行抛出的Throwable实例
void error(ZuulException e) {
RequestContext.getCurrentContext().setThrowable(e);
zuulRunner.error();
}
}

ZuulServletFilterZuulServlet不相同的地方仅仅是初始化和处理方法的方法签名(参数列表和方法名),其他逻辑甚至是代码是一模一样,使用过程中我们需要了解javax.servlet.http.HttpServletjavax.servlet.Filter的作用去选择到底使用ZuulServletFilter还是ZuulServlet。上面的代码可以看到,ZuulServlet初始化的时候可以配置初始化布尔值参数buffer-requests,这个参数默认为false,它是ZuulRunner实例化的必须参数。ZuulServlet中的调用ZuulFilter的方法都委托到ZuulRunner实例去完成,但是我们可以从service(servletRequest, servletResponse)方法看出四种FilterType(pre、route、post、error)的ZuulFilter的执行顺序,总结如下:

  • 1、pre、route、post都不抛出异常,顺序是:pre->route->post,error不执行。
  • 2、pre抛出异常,顺序是:pre->error->post。
  • 3、route抛出异常,顺序是:pre->route->error->post。
  • 4、post抛出异常,顺序是:pre->route->post->error。

注意,一旦出现了异常,会把抛出的Throwable实例设置到绑定到当前请求线程的RequestContext实例中的throwable属性。还需要注意在service(servletRequest, servletResponse)的finally块中调用了RequestContext.getCurrentContext().unset();,实际上是从RequestContextThreadLocal实例中移除当前的RequestContext实例,这样做可以避免ThreadLocal使用不当导致内存泄漏。

接着看ZuulRunner的源码:

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
public class ZuulRunner {

private boolean bufferRequests;

public ZuulRunner() {
this.bufferRequests = true;
}

public ZuulRunner(boolean bufferRequests) {
this.bufferRequests = bufferRequests;
}

public void init(HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
RequestContext ctx = RequestContext.getCurrentContext();
if (bufferRequests) {
ctx.setRequest(new HttpServletRequestWrapper(servletRequest));
} else {
ctx.setRequest(servletRequest);
}
ctx.setResponse(new HttpServletResponseWrapper(servletResponse));
}

public void postRoute() throws ZuulException {
FilterProcessor.getInstance().postRoute();
}

public void route() throws ZuulException {
FilterProcessor.getInstance().route();
}

public void preRoute() throws ZuulException {
FilterProcessor.getInstance().preRoute();
}

public void error() {
FilterProcessor.getInstance().error();
}
}

postRoute()route()preRoute()error()都是直接委托到FilterProcessor中完成的,实际上就是执行对应类型的所有ZuulFilter实例。这里需要注意的是,初始化ZuulRunner时候,HttpServletResponse会被包装为com.netflix.zuul.http.HttpServletResponseWrapper实例,它是Zuul实现的javax.servlet.http.HttpServletResponseWrapper的子类,主要是添加了一个属性status用来记录Http状态码。如果初始化参数bufferRequests为true,HttpServletRequest会被包装为com.netflix.zuul.http.HttpServletRequestWrapper,它是Zuul实现的javax.servlet.http.HttpServletRequestWrapper的子类,这个包装类主要是把请求的表单参数和请求体都缓存在实例属性中,这样在一些特定场景中可以提高性能。如果没有特殊需要,这个参数bufferRequests一般设置为false。

Zuul简单的使用例子

我们做一个很简单的例子,场景是:对于每个POST请求,使用pre类型的ZuulFilter打印它的请求体,然后使用post类型的ZuulFilter,响应结果硬编码为字符串"Hello World!"。我们先为CounterFactory、`TracerFactory添加两个空的子类,因为Zuul处理逻辑中依赖到这两个组件实现数据度量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DefaultTracerFactory extends TracerFactory {

@Override
public Tracer startMicroTracer(String name) {
return null;
}
}

public class DefaultCounterFactory extends CounterFactory {

@Override
public void increment(String name) {

}
}

接着我们分别继承ZuulFilter,实现一个pre类型的用于打印请求参数的Filter,命名为PrintParameterZuulFilter,实现一个post类型的用于返回字符串"Hello World!"的Filter,命名为SendResponseZuulFilter

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
public class PrintParameterZuulFilter extends ZuulFilter {

@Override
public String filterType() {
return "pre";
}

@Override
public int filterOrder() {
return 0;
}

@Override
public boolean shouldFilter() {
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
return "POST".equalsIgnoreCase(request.getMethod());
}

@Override
public Object run() throws ZuulException {
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
if (null != request.getContentType()) {
if (request.getContentType().contains("application/json")) {
try {
ServletInputStream inputStream = request.getInputStream();
String result = StreamUtils.copyToString(inputStream, Charset.forName("UTF-8"));
System.out.println(String.format("请求URI为:%s,请求参数为:%s", request.getRequestURI(), result));
} catch (IOException e) {
throw new ZuulException(e, 500, "从输入流中读取请求参数异常");
}
} else if (request.getContentType().contains("application/x-www-form-urlencoded")) {
StringBuilder params = new StringBuilder();
Enumeration<String> parameterNames = request.getParameterNames();
while (parameterNames.hasMoreElements()) {
String name = parameterNames.nextElement();
params.append(name).append("=").append(request.getParameter(name)).append("&");
}
String result = params.toString();
System.out.println(String.format("请求URI为:%s,请求参数为:%s", request.getRequestURI(),
result.substring(0, result.lastIndexOf("&"))));
}
}
return null;
}
}

public class SendResponseZuulFilter extends ZuulFilter {

@Override
public String filterType() {
return "post";
}

@Override
public int filterOrder() {
return 0;
}

@Override
public boolean shouldFilter() {
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
return "POST".equalsIgnoreCase(request.getMethod());
}

@Override
public Object run() throws ZuulException {
RequestContext context = RequestContext.getCurrentContext();
String output = "Hello World!";
try {
context.getResponse().getWriter().write(output);
} catch (IOException e) {
throw new ZuulException(e, 500, e.getMessage());
}
return true;
}
}

接着,我们引入嵌入式Tomcat,简单地创建一个Servlet容器,Maven依赖为:

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
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>8.5.34</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<version>8.5.34</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jasper</artifactId>
<version>8.5.34</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jasper-el</artifactId>
<version>8.5.34</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jsp-api</artifactId>
<version>8.5.34</version>
</dependency>

添加带main方法的类把上面的组件和Tomcat的组件组装起来:

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
public class ZuulMain {

private static final String WEBAPP_DIRECTORY = "src/main/webapp/";
private static final String ROOT_CONTEXT = "";

public static void main(String[] args) throws Exception {
Tomcat tomcat = new Tomcat();
File tempDir = File.createTempFile("tomcat" + ".", ".8080");
tempDir.delete();
tempDir.mkdir();
tempDir.deleteOnExit();
//创建临时目录,这一步必须先设置,如果不设置默认在当前的路径创建一个'tomcat.8080文件夹'
tomcat.setBaseDir(tempDir.getAbsolutePath());
tomcat.setPort(8080);
StandardContext ctx = (StandardContext) tomcat.addWebapp(ROOT_CONTEXT,
new File(WEBAPP_DIRECTORY).getAbsolutePath());
WebResourceRoot resources = new StandardRoot(ctx);
resources.addPreResources(new DirResourceSet(resources, "/WEB-INF/classes",
new File("target/classes").getAbsolutePath(), "/"));
ctx.setResources(resources);
ctx.setDefaultWebXml(new File("src/main/webapp/WEB-INF/web.xml").getAbsolutePath());
// FixBug: no global web.xml found
for (LifecycleListener ll : ctx.findLifecycleListeners()) {
if (ll instanceof ContextConfig) {
((ContextConfig) ll).setDefaultWebXml(ctx.getDefaultWebXml());
}
}
//这里添加两个度量父类的空实现
CounterFactory.initialize(new DefaultCounterFactory());
TracerFactory.initialize(new DefaultTracerFactory());
//这里添加自实现的ZuulFilter
FilterRegistry.instance().put("printParameterZuulFilter", new PrintParameterZuulFilter());
FilterRegistry.instance().put("sendResponseZuulFilter", new SendResponseZuulFilter());
//这里添加ZuulServlet
Context context = tomcat.addContext("/zuul", null);
Tomcat.addServlet(context, "zuul", new ZuulServlet());
//设置Servlet的路径
context.addServletMappingDecoded("/*", "zuul");
tomcat.start();
tomcat.getServer().await();
}
}

执行main方法,Tomcat正常启动后打印出熟悉的日志如下:

z-s-c-4

接下来,用POSTMAN请求模拟一下请求:

z-s-c-5

小结

Zuul虽然在它的Github仓库中的简介中说它是一个提供动态路由、监视、弹性、安全性等的网关框架,但是实际上它原生并没有提供这些功能,这些功能是需要使用者扩展ZuulFilter实现的,例如基于负载均衡的动态路由需要配置Netflix自己家的Ribbon实现。Zuul在设计上的扩展性什么良好,ZuulFilter就像插件一个可以通过类型、排序系数构建一个调用链,通过Filter或者Servlet做入口,嵌入到Servlet(Web)应用中。不过,在Zuul后续的版本如2.x和3.x中,引入了Netty,基于TCP做底层的扩展,但是编码和使用的复杂度大大提高。也许这就是SpringCloud在netflix-zuul组件中选用了zuul1.x的最后一个发布版本1.3.1的原因吧。springcloud-netflix中使用到Netflix的zuul(动态路由)、robbin(负载均衡)、eureka(服务注册与发现)、hystrix(熔断)等核心组件,这里立个flag先逐个组件分析其源码,逐个击破后再对springcloud-netflix做一次完整的源码分析。

(本文完 c-5-d r-a-20190310 最近996,不能经常更新,顺便祝自己生日快乐…)

项目架构级别规约框架Archunit调研

背景

最近在做一个新项目的时候引入了一个架构方面的需求,就是需要检查项目的编码规范、模块分类规范、类依赖规范等,刚好接触到,正好做个调研。

很多时候,我们会制定项目的规范,例如:

  • 硬性规定项目包结构中service层不能引用controller层的类(这个例子有点极端)。
  • 硬性规定定义在controller包下的Controller类的类名称以"Controller"结尾,方法的入参类型命名以"Request"结尾,返回参数命名以"Response"结尾。
  • 枚举类型必须放在common.constant包下,以类名称Enum结尾。

还有很多其他可能需要定制的规范,最终可能会输出一个文档。但是,谁能保证所有参数开发的人员都会按照文档的规范进行开发?为了保证规范的实行,Archunit以单元测试的形式通过扫描类路径(甚至Jar)包下的所有类,通过单元测试的形式对各个规范进行代码编写,如果项目代码中有违背对应的单测规范,那么单元测试将会不通过,这样就可以从CI/CD层面彻底把控项项目架构和编码规范。

简介

Archunit是一个免费、简单、可扩展的类库,用于检查Java代码的体系结构。提供检查包和类的依赖关系、调用层次和切面的依赖关系、循环依赖检查等其他功能。它通过导入所有类的代码结构,基于Java字节码分析实现这一点。的主要关注点是使用任何普通的Java单元测试框架自动测试代码体系结构和编码规则

引入依赖

一般来说,目前常用的测试框架是Junit4,需要引入Junit4和archunit:

1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit</artifactId>
<version>0.9.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>

由于junit4中依赖到slf4j,因此最好在测试依赖中引入一个slf4j的实现,例如logback:

1
2
3
4
5
6
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
<scope>test</scope>
</dependency>

如何使用

主要从下面的两个方面介绍一下的使用:

  • 指定参数进行类扫描。
  • 内建规则定义。

指定参数进行类扫描

需要对代码或者依赖规则进行判断前提是要导入所有需要分析的类,类扫描导入依赖于ClassFileImporter,底层依赖于ASM字节码框架针对类文件的字节码进行解析,性能会比基于反射的类扫描框架高很多。ClassFileImporter的构造可选参数为ImportOption(s),扫描规则可以通过ImportOption接口实现,默认提供可选的规则有:

1
2
3
4
5
6
7
8
// 不包含测试类
ImportOption.Predefined.DONT_INCLUDE_TESTS

// 不包含Jar包里面的类
ImportOption.Predefined.DONT_INCLUDE_JARS

// 不包含Jar和Jrt包里面的类,JDK9的特性
ImportOption.Predefined.DONT_INCLUDE_ARCHIVES

举个例子,我们实现一个自定义的ImportOption实现,用于指定需要排除扫描的包路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class DontIncludePackagesImportOption implements ImportOption {

private final Set<Pattern> EXCLUDED_PATTERN;

public DontIncludePackagesImportOption(String... packages) {
EXCLUDED_PATTERN = new HashSet<>(8);
for (String eachPackage : packages) {
EXCLUDED_PATTERN.add(Pattern.compile(String.format(".*/%s/.*", eachPackage.replace("/", "."))));
}
}

@Override
public boolean includes(Location location) {
for (Pattern pattern : EXCLUDED_PATTERN) {
if (location.matches(pattern)) {
return false;
}
}
return true;
}
}

ImportOption接口只有一个方法:

1
boolean includes(Location location)

其中,Location包含了路径信息、是否Jar文件等判断属性的元数据,方便使用正则表达式或者直接的逻辑判断。

接着我们可以通过上面实现的DontIncludePackagesImportOption去构造ClassFileImporter实例:

1
2
3
4
5
6
ImportOptions importOptions = new ImportOptions()
// 不扫描jar包
.with(ImportOption.Predefined.DONT_INCLUDE_JARS)
// 排除不扫描的包
.with(new DontIncludePackagesImportOption("com.sample..support"));
ClassFileImporter classFileImporter = new ClassFileImporter(importOptions);

得到ClassFileImporter实例后我们可以通过对应的方法导入项目中的类:

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
// 指定类型导入单个类
public JavaClass importClass(Class<?> clazz)

// 指定类型导入多个类
public JavaClasses importClasses(Class<?>... classes)
public JavaClasses importClasses(Collection<Class<?>> classes)

// 通过指定路径导入类
public JavaClasses importUrl(URL url)
public JavaClasses importUrls(Collection<URL> urls)
public JavaClasses importLocations(Collection<Location> locations)

// 通过类路径导入类
public JavaClasses importClasspath()
public JavaClasses importClasspath(ImportOptions options)

// 通过文件路径导入类
public JavaClasses importPath(String path)
public JavaClasses importPath(Path path)
public JavaClasses importPaths(String... paths)
public JavaClasses importPaths(Path... paths)
public JavaClasses importPaths(Collection<Path> paths)

// 通过Jar文件对象导入类
public JavaClasses importJar(JarFile jar)
public JavaClasses importJars(JarFile... jarFiles)
public JavaClasses importJars(Iterable<JarFile> jarFiles)

// 通过包路径导入类 - 这个是比较常用的方法
public JavaClasses importPackages(Collection<String> packages)
public JavaClasses importPackages(String... packages)
public JavaClasses importPackagesOf(Class<?>... classes)
public JavaClasses importPackagesOf(Collection<Class<?>> classes)

导入类的方法提供了多维度的参数,用起来会十分便捷。例如想导入com.sample包下面的所有类,只需要这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ClassFileImporterTest {

@Test
public void testImportBootstarpClass() throws Exception {
ImportOptions importOptions = new ImportOptions()
// 不扫描jar包
.with(ImportOption.Predefined.DONT_INCLUDE_JARS)
// 排除不扫描的包
.with(new DontIncludePackagesImportOption("com.sample..support"));
ClassFileImporter classFileImporter = new ClassFileImporter(importOptions);
long start = System.currentTimeMillis();
JavaClasses javaClasses = classFileImporter.importPackages("com.sample");
long end = System.currentTimeMillis();
System.out.println(String.format("Found %d classes,cost %d ms", javaClasses.size(), end - start));
}
}

得到的JavaClassesJavaClass的集合,可以简单类比为反射中Class的集合,后面使用的代码规则和依赖规则判断都是强依赖于JavaClasses或者JavaClass

内建规则定义

类扫描和类导入完成之后,我们需要定检查规则,然后应用于所有导入的类,这样子就能完成对所有的类进行规则的过滤 - 或者说把规则应用于所有类并且进行断言。

规则定义依赖于ArchRuleDefinition类,创建出来的规则是ArchRule实例,规则实例的创建过程一般使用ArchRuleDefinition类的流式方法,这些流式方法定义上符合人类思考的思维逻辑,上手比较简单,举个例子:

1
2
3
4
5
6
7
8
9
ArchRule archRule = ArchRuleDefinition.noClasses()
// 在service包下的所有类
.that().resideInAPackage("..service..")
// 不能调用controller包下的任意类
.should().accessClassesThat().resideInAPackage("..controller..")
// 断言描述 - 不满足规则的时候打印出来的原因
.because("不能在service包中调用controller中的类");
// 对所有的JavaClasses进行判断
archRule.check(classes);

上面展示了自定义新的ArchRule的例子,中已经为我们内置了一些常用的ArchRule实现,它们位于GeneralCodingRules中:

  • NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS:不能调用System.out、System.err或者(Exception.)printStackTrace。
  • NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS:类不能直接抛出通用异常Throwable、Exception或者RuntimeException。
  • NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING:不能使用java.util.logging包路径下的日志组件。

更多内建的ArchRule或者通用的内置规则使用,可以参考官方例子

基本使用例子

基本使用例子,主要从一些常见的编码规范或者项目规范编写规则对项目所有类进行检查。

包依赖关系检查

j-a-r-u-1

1
2
3
ArchRule archRule = ArchRuleDefinition.noClasses()
.that().resideInAPackage("..com.source..")
.should().dependOnClassesThat().resideInAPackage("..com.target..");

j-a-r-u-2

1
2
3
ArchRule archRule = ArchRuleDefinition.classes()
.that().resideInAPackage("..com.foo..")
.should().onlyAccessClassesThat().resideInAnyPackage("..com.source..", "..com.foo..");

类依赖关系检查

j-a-r-u-3

1
2
3
ArchRule archRule = ArchRuleDefinition.classes()
.that().haveNameMatching(".*Bar")
.should().onlyBeAccessed().byClassesThat().haveSimpleName("Bar");

类包含于包的关系检查

j-a-r-u-4

1
2
3
ArchRule archRule = ArchRuleDefinition.classes()
.that().haveSimpleNameStartingWith("Foo")
.should().resideInAPackage("com.foo");

继承关系检查

j-a-r-u-5

1
2
3
ArchRule archRule = ArchRuleDefinition.classes()
.that().implement(Collection.class)
.should().haveSimpleNameEndingWith("Connection");

j-a-r-u-6

1
2
3
ArchRule archRule = ArchRuleDefinition.classes()
.that().areAssignableTo(EntityManager.class)
.should().onlyBeAccessed().byAnyPackage("..persistence..");

注解检查

j-a-r-u-7

1
2
3
ArchRule archRule = ArchRuleDefinition.classes()
.that().areAssignableTo(EntityManager.class)
.should().onlyBeAccessed().byClassesThat().areAnnotatedWith(Transactional.class)

逻辑层调用关系检查

例如项目结构如下:

1
2
3
4
5
6
7
8
- com.myapp.controller
SomeControllerOne.class
SomeControllerTwo.class
- com.myapp.service
SomeServiceOne.class
SomeServiceTwo.class
- com.myapp.persistence
SomePersistenceManager

例如我们规定:

  • 包路径com.myapp.controller中的类不能被其他层级包引用。
  • 包路径com.myapp.service中的类只能被com.myapp.controller中的类引用。
  • 包路径com.myapp.persistence中的类只能被com.myapp.service中的类引用。

编写规则如下:

1
2
3
4
5
6
7
8
layeredArchitecture()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Persistence").definedBy("..persistence..")

.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service")

循环依赖关系检查

例如项目结构如下:

1
2
3
4
5
6
7
8
9
- com.myapp.moduleone
ClassOneInModuleOne.class
ClassTwoInModuleOne.class
- com.myapp.moduletwo
ClassOneInModuleTwo.class
ClassTwoInModuleTwo.class
- com.myapp.modulethree
ClassOneInModuleThree.class
ClassTwoInModuleThree.class

例如我们规定:com.myapp.moduleonecom.myapp.moduletwocom.myapp.modulethree三个包路径中的类不能形成一个循环依赖缓,例如:

1
ClassOneInModuleOne -> ClassOneInModuleTwo -> ClassOneInModuleThree -> ClassOneInModuleOne

编写规则如下:

1
slices().matching("com.myapp.(*)..").should().beFreeOfCycles()

核心API

把API分为三层,最重要的是"Core"层、"Lang"层和"Library"层。

Core层API

ArchUnit的Core层API大部分类似于Java原生反射API,例如JavaMethodJavaField对应于原生反射中的MethodField,它们提供了诸如getName()getMethods()getType()getParameters()等方法。

此外ArchUnit扩展一些API用于描述依赖代码之间关系,例如JavaMethodCallJavaConstructorCallJavaFieldAccess。还提供了例如Java类与其他Java类之间的导入访问关系的API如JavaClass#getAccessesFromSelf()

而需要导入类路径下或者Jar包中已经编译好的Java类,ArchUnit提供了ClassFileImporter完成此功能:

1
JavaClasses classes = new ClassFileImporter().importPackages("com.mycompany.myapp");

Lang层API

Core层的API十分强大,提供了需要关于Java程序静态结构的信息,但是直接使用Core层的API对于单元测试会缺乏表现力,特别表现在架构规则方面。

出于这个原因,ArchUnit提供了Lang层的API,它提供了一种强大的语法来以抽象的方式表达规则。Lang层的API大多数是采用流式编程方式定义方法,例如指定包定义和调用关系的规则如下:

1
2
3
4
5
6
ArchRule rule =
classes()
// 定义在service包下的所欲类
.that().resideInAPackage("..service..")
// 只能被controller包或者service包中的类访问
.should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");

编写好规则后就可以基于导入所有编译好的类进行扫描:

1
2
3
JavaClasses classes = new ClassFileImporter().importPackage("com.myapp");
ArchRule rule = // 定义的规则
rule.check(classes);

Library层API

Library层API通过静态工厂方法提供了更多复杂而强大的预定义规则,入口类是:

1
com.tngtech.archunit.library.Architectures

目前,这只能为分层架构提供方便的检查,但将来可能会扩展为六边形架构\管道和过滤器,业务逻辑和技术基础架构的分离等样式。

还有其他几个相对强大的功能:

  • 代码切片功能,入口是com.tngtech.archunit.library.dependencies.SlicesRuleDefinition
  • 一般编码规则,入口是com.tngtech.archunit.library.GeneralCodingRules
  • PlantUML组件支持,功能位于包路径com.tngtech.archunit.library.plantuml下。

编写复杂的规则

一般来说,内建的规则不一定能够满足一些复杂的规范校验规则,因此需要编写自定义的规则。这里仅仅举一个前文提到的相对复杂的规则:

  • 定义在controller包下的Controller类的类名称以"Controller"结尾,方法的入参类型命名以"Request"结尾,返回参数命名以"Response"结尾。

官方提供的自定义规则的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
DescribedPredicate<JavaClass> haveAFieldAnnotatedWithPayload =
new DescribedPredicate<JavaClass>("have a field annotated with @Payload"){
@Override
public boolean apply(JavaClass input) {
boolean someFieldAnnotatedWithPayload = // iterate fields and check for @Payload
return someFieldAnnotatedWithPayload;
}
};

ArchCondition<JavaClass> onlyBeAccessedBySecuredMethods =
new ArchCondition<JavaClass>("only be accessed by @Secured methods") {
@Override
public void check(JavaClass item, ConditionEvents events) {
for (JavaMethodCall call : item.getMethodCallsToSelf()) {
if (!call.getOrigin().isAnnotatedWith(Secured.class)) {
String message = String.format(
"Method %s is not @Secured", call.getOrigin().getFullName());
events.add(SimpleConditionEvent.violated(call, message));
}
}
}
};

classes().that(haveAFieldAnnotatedWithPayload).should(onlyBeAccessedBySecuredMethods);

我们只需要模仿它的实现即可,具体如下:

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
48
49
50
51
52
public class ArchunitTest {

@Test
public void controller_class_rule() {
JavaClasses classes = new ClassFileImporter().importPackages("club.throwable");
DescribedPredicate<JavaClass> predicate =
new DescribedPredicate<JavaClass>("定义在club.throwable.controller包下的所有类") {
@Override
public boolean apply(JavaClass input) {
return null != input.getPackageName() && input.getPackageName().contains("club.throwable.controller");
}
};
ArchCondition<JavaClass> condition1 = new ArchCondition<JavaClass>("类名称以Controller结尾") {
@Override
public void check(JavaClass javaClass, ConditionEvents conditionEvents) {
String name = javaClass.getName();
if (!name.endsWith("Controller")) {
conditionEvents.add(SimpleConditionEvent.violated(javaClass, String.format("当前控制器类[%s]命名不以\"Controller\"结尾", name)));
}
}
};
ArchCondition<JavaClass> condition2 = new ArchCondition<JavaClass>("方法的入参类型命名以\"Request\"结尾,返回参数命名以\"Response\"结尾") {
@Override
public void check(JavaClass javaClass, ConditionEvents conditionEvents) {
Set<JavaMethod> javaMethods = javaClass.getMethods();
String className = javaClass.getName();
// 其实这里要做严谨一点需要考虑是否使用了泛型参数,这里暂时简化了
for (JavaMethod javaMethod : javaMethods) {
Method method = javaMethod.reflect();
Class<?>[] parameterTypes = method.getParameterTypes();
for (Class parameterType : parameterTypes) {
if (!parameterType.getName().endsWith("Request")) {
conditionEvents.add(SimpleConditionEvent.violated(method,
String.format("当前控制器类[%s]的[%s]方法入参不以\"Request\"结尾", className, method.getName())));
}
}
Class<?> returnType = method.getReturnType();
if (!returnType.getName().endsWith("Response")) {
conditionEvents.add(SimpleConditionEvent.violated(method,
String.format("当前控制器类[%s]的[%s]方法返回参数不以\"Response\"结尾", className, method.getName())));
}
}
}
};
ArchRuleDefinition.classes()
.that(predicate)
.should(condition1)
.andShould(condition2)
.because("定义在controller包下的Controller类的类名称以\"Controller\"结尾,方法的入参类型命名以\"Request\"结尾,返回参数命名以\"Response\"结尾")
.check(classes);
}
}

因为导入了所有需要的编译好的类的静态属性,基本上是可以编写所有能够想出来的规约,更多的内容或者实现可以自行摸索。

小结

通过最近的一个项目引入了Archunit,并且进行了一些编码规范和架构规范的规约,起到了十分明显的效果。之前口头或者书面文档的规范可以通过单元测试直接控制,项目构建的时候强制必须执行单元测试,只有所有单测通过才能构建和打包(禁止使用-Dmaven.test.skip=true参数),起到了十分明显的成效。

参考资料:

(e-a-2019216 c-1-d)

JVM应用度量框架Micrometer实战

前提

最近线上的项目使用了spring-actuator做度量统计收集,使用Prometheus进行数据收集,Grafana进行数据展示,用于监控生成环境机器的性能指标和业务数据指标。一般,我们叫这样的操作为"埋点"。SpringBoot中的依赖spring-actuator中集成的度量统计API使用的框架是Micrometer,官网是Micrometer.io。在实践中发现了业务开发者滥用了Micrometer的度量类型Counter,导致无论什么情况下都只使用计数统计的功能。这篇文章就是基于Micrometer分析其他的度量类型API的作用和适用场景。

Micrometer提供的度量类库

Meter是指一组用于收集应用中的度量数据的接口,Meter单词可以翻译为"米"或者"千分尺",但是显然听起来都不是很合理,因此下文直接叫Meter,理解它为度量接口即可。Meter是由MeterRegistry创建和保存的,可以理解MeterRegistryMeter的工厂和缓存中心,一般而言每个JVM应用在使用Micrometer的时候必须创建一个MeterRegistry的具体实现。Micrometer中,Meter的具体类型包括:TimerCounterGaugeDistributionSummaryLongTaskTimerFunctionCounterFunctionTimerTimeGauge。下面分节详细介绍这些类型的使用方法和实战使用场景。而一个Meter具体类型需要通过名字和Tag(这里指的是Micrometer提供的Tag接口)作为它的唯一标识,这样做的好处是可以使用名字进行标记,通过不同的Tag去区分多种维度进行数据统计。

MeterRegistry

MeterRegistry在Micrometer是一个抽象类,主要实现包括:

  • 1、SimpleMeterRegistry:每个Meter的最新数据可以收集到SimpleMeterRegistry实例中,但是这些数据不会发布到其他系统,也就是数据是位于应用的内存中的。
  • 2、CompositeMeterRegistry:多个MeterRegistry聚合,内部维护了一个MeterRegistry的列表。
  • 3、全局的MeterRegistry:工厂类io.micrometer.core.instrument.Metrics中持有一个静态final的CompositeMeterRegistry实例globalRegistry。

当然,使用者也可以自行继承MeterRegistry去实现自定义的MeterRegistry。SimpleMeterRegistry适合做调试的时候使用,它的简单使用方式如下:

1
2
3
MeterRegistry registry = new SimpleMeterRegistry();
Counter counter = registry.counter("counter");
counter.increment();

CompositeMeterRegistry实例初始化的时候,内部持有的MeterRegistry列表是空的,如果此时用它新增一个Meter实例,Meter实例的操作是无效的:

1
2
3
4
5
6
7
8
9
CompositeMeterRegistry composite = new CompositeMeterRegistry();

Counter compositeCounter = composite.counter("counter");
compositeCounter.increment(); // <- 实际上这一步操作是无效的,但是不会报错

SimpleMeterRegistry simple = new SimpleMeterRegistry();
composite.add(simple); // <- 向CompositeMeterRegistry实例中添加SimpleMeterRegistry实例

compositeCounter.increment(); // <-计数成功

全局的MeterRegistry的使用方式更加简单便捷,因为一切只需要操作工厂类Metrics的静态方法:

1
2
3
Metrics.addRegistry(new SimpleMeterRegistry());
Counter counter = Metrics.counter("counter", "tag-1", "tag-2");
counter.increment();

Tag与Meter的命名

Micrometer中,Meter的命名约定使用英文逗号(dot,也就是".")分隔单词。但是对于不同的监控系统,对命名的规约可能并不相同,如果命名规约不一致,在做监控系统迁移或者切换的时候,可能会对新的系统造成破坏。Micrometer中使用英文逗号分隔单词的命名规则,再通过底层的命名转换接口NamingConvention进行转换,最终可以适配不同的监控系统,同时可以消除监控系统不允许的特殊字符的名称和标记等。开发者也可以覆盖NamingConvention实现自定义的命名转换规则:registry.config().namingConvention(myCustomNamingConvention);。在Micrometer中,对一些主流的监控系统或者存储系统的命名规则提供了默认的转换方式,例如当我们使用下面的命名时候:

1
2
MeterRegistry registry = ...
registry.timer("http.server.requests");

对于不同的监控系统或者存储系统,命名会自动转换如下:

  • 1、Prometheus - http_server_requests_duration_seconds。
  • 2、Atlas - httpServerRequests。
  • 3、Graphite - http.server.requests。
  • 4、InfluxDB - http_server_requests。

其实NamingConvention已经提供了5种默认的转换规则:dot、snakeCase、camelCase、upperCamelCase和slashes。

另外,Tag(标签)是Micrometer的一个重要的功能,严格来说,一个度量框架只有实现了标签的功能,才能真正地多维度进行度量数据收集。Tag的命名一般需要是有意义的,所谓有意义就是可以根据Tag的命名可以推断出它指向的数据到底代表什么维度或者什么类型的度量指标。假设我们需要监控数据库的调用和Http请求调用统计,一般推荐的做法是:

1
2
3
MeterRegistry registry = ...
registry.counter("database.calls", "db", "users")
registry.counter("http.requests", "uri", "/api/users")

这样,当我们选择命名为"database.calls"的计数器,我们可以进一步选择分组"db"或者"users"分别统计不同分组对总调用数的贡献或者组成。一个反例如下:

1
2
3
4
5
6
7
8
MeterRegistry registry = ...
registry.counter("calls",
"class", "database",
"db", "users");

registry.counter("calls",
"class", "http",
"uri", "/api/users");

通过命名"calls"得到的计数器,由于标签混乱,数据是基本无法分组统计分析,这个时候可以认为得到的时间序列的统计数据是没有意义的。可以定义全局的Tag,也就是全局的Tag定义之后,会附加到所有的使用到的Meter上(只要是使用同一个MeterRegistry),全局的Tag可以这样定义:

1
2
3
4
MeterRegistry registry = ...
registry.config().commonTags("stack", "prod", "region", "us-east-1");
// 和上面的意义是一样的
registry.config().commonTags(Arrays.asList(Tag.of("stack", "prod"), Tag.of("region", "us-east-1")));

像上面这样子使用,就能通过主机,实例,区域,堆栈等操作环境进行多维度深入分析。

还有两点点需要注意:

  • 1、Tag的值必须不为null。
  • 2、Micrometer中,Tag必须成对出现,也就是Tag必须设置为偶数个,实际上它们以Key=Value的形式存在,具体可以看io.micrometer.core.instrument.Tag接口:
1
2
3
4
5
6
7
8
9
10
11
12
13
public interface Tag extends Comparable<Tag> {
String getKey();

String getValue();

static Tag of(String key, String value) {
return new ImmutableTag(key, value);
}

default int compareTo(Tag o) {
return this.getKey().compareTo(o.getKey());
}
}

当然,有些时候,我们需要过滤一些必要的标签或者名称进行统计,或者为Meter的名称添加白名单,这个时候可以使用MeterFilterMeterFilter本身提供一些列的静态方法,多个MeterFilter可以叠加或者组成链实现用户最终的过滤策略。例如:

1
2
3
4
MeterRegistry registry = ...
registry.config()
.meterFilter(MeterFilter.ignoreTags("http"))
.meterFilter(MeterFilter.denyNameStartsWith("jvm"));

表示忽略"http"标签,拒绝名称以"jvm"字符串开头的Meter。更多用法可以参详一下MeterFilter这个类。

Meter的命名和Meter的Tag相互结合,以命名为轴心,以Tag为多维度要素,可以使度量数据的维度更加丰富,便于统计和分析。

Meters

前面提到Meter主要包括:TimerCounterGaugeDistributionSummaryLongTaskTimerFunctionCounterFunctionTimerTimeGauge。下面逐一分析它们的作用和个人理解的实际使用场景(应该说是生产环境)。

Counter

Counter是一种比较简单的Meter,它是一种单值的度量类型,或者说是一个单值计数器。Counter接口允许使用者使用一个固定值(必须为正数)进行计数。准确来说:Counter就是一个增量为正数的单值计数器。这个举个很简单的使用例子:

1
2
3
4
MeterRegistry meterRegistry = new SimpleMeterRegistry();
Counter counter = meterRegistry.counter("http.request", "createOrder", "/order/create");
counter.increment();
System.out.println(counter.measure()); // [Measurement{statistic='COUNT', value=1.0}]

使用场景:

Counter的作用是记录XXX的总量或者计数值,适用于一些增长类型的统计,例如下单、支付次数、Http请求总量记录等等,通过Tag可以区分不同的场景,对于下单,可以使用不同的Tag标记不同的业务来源或者是按日期划分,对于Http请求总量记录,可以使用Tag区分不同的URL。用下单业务举个例子:

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
48
49
50
51
//实体
@Data
public class Order {

private String orderId;
private Integer amount;
private String channel;
private LocalDateTime createTime;
}


public class CounterMain {

private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");

static {
Metrics.addRegistry(new SimpleMeterRegistry());
}

public static void main(String[] args) throws Exception {
Order order1 = new Order();
order1.setOrderId("ORDER_ID_1");
order1.setAmount(100);
order1.setChannel("CHANNEL_A");
order1.setCreateTime(LocalDateTime.now());
createOrder(order1);
Order order2 = new Order();
order2.setOrderId("ORDER_ID_2");
order2.setAmount(200);
order2.setChannel("CHANNEL_B");
order2.setCreateTime(LocalDateTime.now());
createOrder(order2);
Search.in(Metrics.globalRegistry).meters().forEach(each -> {
StringBuilder builder = new StringBuilder();
builder.append("name:")
.append(each.getId().getName())
.append(",tags:")
.append(each.getId().getTags())
.append(",type:").append(each.getId().getType())
.append(",value:").append(each.measure());
System.out.println(builder.toString());
});
}

private static void createOrder(Order order) {
//忽略订单入库等操作
Metrics.counter("order.create",
"channel", order.getChannel(),
"createTime", FORMATTER.format(order.getCreateTime())).increment();
}
}

控制台输出:

1
2
name:order.create,tags:[tag(channel=CHANNEL_A), tag(createTime=2018-11-10)],type:COUNTER,value:[Measurement{statistic='COUNT', value=1.0}]
name:order.create,tags:[tag(channel=CHANNEL_B), tag(createTime=2018-11-10)],type:COUNTER,value:[Measurement{statistic='COUNT', value=1.0}]

上面的例子是使用全局静态方法工厂类Metrics去构造Counter实例,实际上,io.micrometer.core.instrument.Counter接口提供了一个内部建造器类Counter.Builder去实例化Counter,Counter.Builder的使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
public class CounterBuilderMain {

public static void main(String[] args) throws Exception{
Counter counter = Counter.builder("name") //名称
.baseUnit("unit") //基础单位
.description("desc") //描述
.tag("tagKey", "tagValue") //标签
.register(new SimpleMeterRegistry());//绑定的MeterRegistry
counter.increment();
}
}

FunctionCounter

FunctionCounterCounter的特化类型,它把计数器数值增加的动作抽象成接口类型ToDoubleFunction,这个接口JDK1.8中对于Function的特化类型接口。FunctionCounter的使用场景和Counter是一致的,这里介绍一下它的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class FunctionCounterMain {

public static void main(String[] args) throws Exception {
MeterRegistry registry = new SimpleMeterRegistry();
AtomicInteger n = new AtomicInteger(0);
//这里ToDoubleFunction匿名实现其实可以使用Lambda表达式简化为AtomicInteger::get
FunctionCounter.builder("functionCounter", n, new ToDoubleFunction<AtomicInteger>() {
@Override
public double applyAsDouble(AtomicInteger value) {
return value.get();
}
}).baseUnit("function")
.description("functionCounter")
.tag("createOrder", "CHANNEL-A")
.register(registry);
//下面模拟三次计数
n.incrementAndGet();
n.incrementAndGet();
n.incrementAndGet();
}
}

FunctionCounter使用的一个明显的好处是,我们不需要感知FunctionCounter实例的存在,实际上我们只需要操作作为FunctionCounter实例构建元素之一的AtomicInteger实例即可,这种接口的设计方式在很多框架里面都可以看到。

Timer

Timer(计时器)适用于记录耗时比较短的事件的执行时间,通过时间分布展示事件的序列和发生频率。所有的Timer的实现至少记录了发生的事件的数量和这些事件的总耗时,从而生成一个时间序列。Timer的基本单位基于服务端的指标而定,但是实际上我们不需要过于关注Timer的基本单位,因为Micrometer在存储生成的时间序列的时候会自动选择适当的基本单位。Timer接口提供的常用方法如下:

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
public interface Timer extends Meter {
...
void record(long var1, TimeUnit var3);

default void record(Duration duration) {
this.record(duration.toNanos(), TimeUnit.NANOSECONDS);
}

<T> T record(Supplier<T> var1);

<T> T recordCallable(Callable<T> var1) throws Exception;

void record(Runnable var1);

default Runnable wrap(Runnable f) {
return () -> {
this.record(f);
};
}

default <T> Callable<T> wrap(Callable<T> f) {
return () -> {
return this.recordCallable(f);
};
}

long count();

double totalTime(TimeUnit var1);

default double mean(TimeUnit unit) {
return this.count() == 0L ? 0.0D : this.totalTime(unit) / (double)this.count();
}

double max(TimeUnit var1);
...
}

实际上,比较常用和方便的方法是几个函数式接口入参的方法:

1
2
3
4
5
6
Timer timer = ...
timer.record(() -> dontCareAboutReturnValue());
timer.recordCallable(() -> returnValue());

Runnable r = timer.wrap(() -> dontCareAboutReturnValue());
Callable c = timer.wrap(() -> returnValue());

使用场景:

根据个人经验和实践,总结如下:

  • 1、记录指定方法的执行时间用于展示。
  • 2、记录一些任务的执行时间,从而确定某些数据来源的速率,例如消息队列消息的消费速率等。

这里举个实际的例子,要对系统做一个功能,记录指定方法的执行时间,还是用下单方法做例子:

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
public class TimerMain {

private static final Random R = new Random();

static {
Metrics.addRegistry(new SimpleMeterRegistry());
}

public static void main(String[] args) throws Exception {
Order order1 = new Order();
order1.setOrderId("ORDER_ID_1");
order1.setAmount(100);
order1.setChannel("CHANNEL_A");
order1.setCreateTime(LocalDateTime.now());
Timer timer = Metrics.timer("timer", "createOrder", "cost");
timer.record(() -> createOrder(order1));
}

private static void createOrder(Order order) {
try {
TimeUnit.SECONDS.sleep(R.nextInt(5)); //模拟方法耗时
} catch (InterruptedException e) {
//no-op
}
}
}

在实际生产环境中,可以通过spring-aop把记录方法耗时的逻辑抽象到一个切面中,这样就能减少不必要的冗余的模板代码。上面的例子是通过Mertics构造Timer实例,实际上也可以使用Builder构造:

1
2
3
4
5
6
MeterRegistry registry = ...
Timer timer = Timer
.builder("my.timer")
.description("a description of what this timer does") // 可选
.tags("region", "test") // 可选
.register(registry);

另外,Timer的使用还可以基于它的内部类Timer.Sample,通过start和stop两个方法记录两者之间的逻辑的执行耗时。例如:

1
2
3
4
5
6
Timer.Sample sample = Timer.start(registry);

// 这里做业务逻辑
Response response = ...

sample.stop(registry.timer("my.timer", "response", response.status()));

FunctionTimer

FunctionTimer是Timer的特化类型,它主要提供两个单调递增的函数(其实并不是单调递增,只是在使用中一般需要随着时间最少保持不变或者说不减少):一个用于计数的函数和一个用于记录总调用耗时的函数,它的建造器的入参如下:

1
2
3
4
5
6
7
8
public interface FunctionTimer extends Meter {
static <T> Builder<T> builder(String name, T obj, ToLongFunction<T> countFunction,
ToDoubleFunction<T> totalTimeFunction,
TimeUnit totalTimeFunctionUnit) {
return new Builder<>(name, obj, countFunction, totalTimeFunction, totalTimeFunctionUnit);
}
...
}

官方文档中的例子如下:

1
2
3
4
5
6
IMap<?, ?> cache = ...; // 假设使用了Hazelcast缓存
registry.more().timer("cache.gets.latency", Tags.of("name", cache.getName()), cache,
c -> c.getLocalMapStats().getGetOperationCount(), //实际上就是cache的一个方法,记录缓存生命周期初始化的增量(个数)
c -> c.getLocalMapStats().getTotalGetLatency(), // Get操作的延迟时间总量,可以理解为耗时
TimeUnit.NANOSECONDS
);

按照个人理解,ToDoubleFunction用于统计事件个数,ToDoubleFunction用于记录执行总时间,实际上两个函数都只是Function函数的变体,还有一个比较重要的是总时间的单位totalTimeFunctionUnit。简单的使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class FunctionTimerMain {

public static void main(String[] args) throws Exception {
//这个是为了满足参数,暂时不需要理会
Object holder = new Object();
AtomicLong totalTimeNanos = new AtomicLong(0);
AtomicLong totalCount = new AtomicLong(0);
FunctionTimer.builder("functionTimer", holder, p -> totalCount.get(),
p -> totalTimeNanos.get(), TimeUnit.NANOSECONDS)
.register(new SimpleMeterRegistry());
totalTimeNanos.addAndGet(10000000);
totalCount.incrementAndGet();
}
}

LongTaskTimer

LongTaskTimer也是一种Timer的特化类型,主要用于记录长时间执行的任务的持续时间,在任务完成之前,被监测的事件或者任务仍然处于运行状态,任务完成的时候,任务执行的总耗时才会被记录下来。LongTaskTimer适合用于长时间持续运行的事件耗时的记录,例如相对耗时的定时任务。在Spring应用中,可以简单地使用@Scheduled和@Timed注解,基于spring-aop完成定时调度任务的总耗时记录:

1
2
3
4
5
@Timed(value = "aws.scrape", longTask = true)
@Scheduled(fixedDelay = 360000)
void scrapeResources() {
//这里做相对耗时的业务逻辑
}

当然,在非spring体系中也能方便地使用LongTaskTimer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class LongTaskTimerMain {

public static void main(String[] args) throws Exception{
MeterRegistry meterRegistry = new SimpleMeterRegistry();
LongTaskTimer longTaskTimer = meterRegistry.more().longTaskTimer("longTaskTimer");
longTaskTimer.record(() -> {

//这里编写Task的逻辑
});
//或者这样
Metrics.more().longTaskTimer("longTaskTimer").record(()-> {
//这里编写Task的逻辑
});
}
}

Gauge

Gauge(仪表)是获取当前度量记录值的句柄,也就是它表示一个可以任意上下浮动的单数值度量Meter。Gauge通常用于变动的测量值,测量值用ToDoubleFunction参数的返回值设置,如当前的内存使用情况,同时也可以测量上下移动的"计数",比如队列中的消息数量。官网文档中提到Gauge的典型使用场景是用于测量集合或映射的大小或运行状态中的线程数。Gauge一般用于监测有自然上界的事件或者任务,而Counter一般使用于无自然上界的事件或者任务的监测,所以像Http请求总量计数应该使用Counter而非Gauge。MeterRegistry中提供了一些便于构建用于观察数值、函数、集合和映射的Gauge相关的方法:

1
2
3
List<String> list = registry.gauge("listGauge", Collections.emptyList(), new ArrayList<>(), List::size); 
List<String> list2 = registry.gaugeCollectionSize("listSize2", Tags.empty(), new ArrayList<>());
Map<String, Integer> map = registry.gaugeMapSize("mapGauge", Tags.empty(), new HashMap<>());

上面的三个方法通过MeterRegistry构建Gauge并且返回了集合或者映射实例,使用这些集合或者映射实例就能在其size变化过程中记录这个变更值。更重要的优点是,我们不需要感知Gauge接口的存在,只需要像平时一样使用集合或者映射实例就可以了。此外,Gauge还支持java.lang.Number的子类,java.util.concurrent.atomic包中的AtomicIntegerAtomicLong,还有Guava提供的AtomicDouble

1
2
3
AtomicInteger n = registry.gauge("numberGauge", new AtomicInteger(0));
n.set(1);
n.set(2);

除了使用MeterRegistry创建Gauge之外,还可以使用建造器流式创建:

1
2
3
4
5
6
//一般我们不需要操作Gauge实例
Gauge gauge = Gauge
.builder("gauge", myObj, myObj::gaugeValue)
.description("a description of what this gauge does") // 可选
.tags("region", "test") // 可选
.register(registry);

使用场景:

根据个人经验和实践,总结如下:

  • 1、有自然(物理)上界的浮动值的监测,例如物理内存、集合、映射、数值等。
  • 2、有逻辑上界的浮动值的监测,例如积压的消息、(线程池中)积压的任务等,其实本质也是集合或者映射的监测。

举个相对实际的例子,假设我们需要对登录后的用户发送一条短信或者推送,做法是消息先投放到一个阻塞队列,再由一个线程消费消息进行其他操作:

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
public class GaugeMain {

private static final MeterRegistry MR = new SimpleMeterRegistry();
private static final BlockingQueue<Message> QUEUE = new ArrayBlockingQueue<>(500);
private static BlockingQueue<Message> REAL_QUEUE;

static {
REAL_QUEUE = MR.gauge("messageGauge", QUEUE, Collection::size);
}

public static void main(String[] args) throws Exception {
consume();
Message message = new Message();
message.setUserId(1L);
message.setContent("content");
REAL_QUEUE.put(message);
}

private static void consume() throws Exception {
new Thread(() -> {
while (true) {
try {
Message message = REAL_QUEUE.take();
//handle message
System.out.println(message);
} catch (InterruptedException e) {
//no-op
}
}
}).start();
}
}

上面的例子代码写得比较糟糕,只为了演示相关使用方式,切勿用于生产环境。

TimeGauge

TimeGauge是Gauge的特化类型,相比Gauge,它的构建器中多了一个TimeUnit类型的参数,用于指定ToDoubleFunction入参的基础时间单位。这里简单举个使用例子:

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
public class TimeGaugeMain {

private static final SimpleMeterRegistry R = new SimpleMeterRegistry();

public static void main(String[] args) throws Exception{
AtomicInteger count = new AtomicInteger();
TimeGauge.Builder<AtomicInteger> timeGauge = TimeGauge.builder("timeGauge", count,
TimeUnit.SECONDS, AtomicInteger::get);
timeGauge.register(R);
count.addAndGet(10086);
print();
count.set(1);
print();
}

private static void print()throws Exception{
Search.in(R).meters().forEach(each -> {
StringBuilder builder = new StringBuilder();
builder.append("name:")
.append(each.getId().getName())
.append(",tags:")
.append(each.getId().getTags())
.append(",type:").append(each.getId().getType())
.append(",value:").append(each.measure());
System.out.println(builder.toString());
});
}
}

//输出
name:timeGauge,tags:[],type:GAUGE,value:[Measurement{statistic='VALUE', value=10086.0}]
name:timeGauge,tags:[],type:GAUGE,value:[Measurement{statistic='VALUE', value=1.0}]

DistributionSummary

Summary(摘要)主要用于跟踪事件的分布,在Micrometer中,对应的类是DistributionSummary(分发摘要)。它的使用方式和Timer十分相似,但是它的记录值并不依赖于时间单位。常见的使用场景:使用DistributionSummary测量命中服务器的请求的有效负载大小。使用MeterRegistry创建DistributionSummary实例如下:

1
DistributionSummary summary = registry.summary("response.size");

通过建造器流式创建如下:

1
2
3
4
5
6
7
DistributionSummary summary = DistributionSummary
.builder("response.size")
.description("a description of what this summary does") // 可选
.baseUnit("bytes") // 可选
.tags("region", "test") // 可选
.scale(100) // 可选
.register(registry);

DistributionSummary中有很多构建参数跟缩放和直方图的表示相关,见下一节。

使用场景:

根据个人经验和实践,总结如下:

  • 1、不依赖于时间单位的记录值的测量,例如服务器有效负载值,缓存的命中率等。

举个相对具体的例子:

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
public class DistributionSummaryMain {

private static final DistributionSummary DS = DistributionSummary.builder("cacheHitPercent")
.register(new SimpleMeterRegistry());

private static final LoadingCache<String, String> CACHE = CacheBuilder.newBuilder()
.maximumSize(1000)
.recordStats()
.expireAfterWrite(60, TimeUnit.SECONDS)
.build(new CacheLoader<String, String>() {
@Override
public String load(String s) throws Exception {
return selectFromDatabase();
}
});

public static void main(String[] args) throws Exception{
String key = "doge";
String value = CACHE.get(key);
record();
}

private static void record()throws Exception{
CacheStats stats = CACHE.stats();
BigDecimal hitCount = new BigDecimal(stats.hitCount());
BigDecimal requestCount = new BigDecimal(stats.requestCount());
DS.record(hitCount.divide(requestCount,2,BigDecimal.ROUND_HALF_DOWN).doubleValue());
}
}

直方图和百分数配置

直方图和百分数配置适用于Summary和Timer,这部分相对复杂,等研究透了再补充。

基于SpirngBoot、Prometheus、Grafana集成

集成了Micrometer框架的JVM应用使用到Micrometer的API收集的度量数据位于内存之中,因此,需要额外的存储系统去存储这些度量数据,需要有监控系统负责统一收集和处理这些数据,还需要有一些UI工具去展示数据,一般大佬只喜欢看炫酷的图表或者动画。常见的存储系统就是时序数据库,主流的有Influx、Datadog等。比较主流的监控系统(主要是用于数据收集和处理)就是Prometheus(一般叫普罗米修斯,下面就这样叫吧)。而展示的UI目前相对用得比较多的就是Grafana。另外,Prometheus已经内置了一个时序数据库的实现,因此,在做一套相对完善的度量数据监控的系统只需要依赖目标JVM应用,Prometheus组件和Grafana组件即可。下面花一点时间从零开始搭建一个这样的系统,之前写的一篇文章基于Windows系统,操作可能跟生产环境不够接近,这次使用CentOS7。

SpirngBoot中使用Micrometer

SpringBoot中的spring-boot-starter-actuator依赖已经集成了对Micrometer的支持,其中的metrics端点的很多功能就是通过Micrometer实现的,prometheus端点默认也是开启支持的,实际上actuator依赖的spring-boot-actuator-autoconfigure中集成了对很多框架的开箱即用的API,其中prometheus包中集成了对Prometheus的支持,使得使用了actuator可以轻易地让项目暴露出prometheus端点,作为Prometheus收集数据的客户端,Prometheus(服务端软件)可以通过此端点收集应用中Micrometer的度量数据。

jvm-m-1.png

我们先引入spring-boot-starter-actuatorspring-boot-starter-web,实现一个CounterTimer作为示例。依赖:

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
 <dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.1.0.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.22</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<version>1.1.0</version>
</dependency>
</dependencies>

接着编写一个下单接口和一个消息发送模块,模拟用户下单之后向用户发送消息:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
//实体
@Data
public class Message {

private String orderId;
private Long userId;
private String content;
}

@Data
public class Order {

private String orderId;
private Long userId;
private Integer amount;
private LocalDateTime createTime;
}

//控制器和服务类
@RestController
public class OrderController {

@Autowired
private OrderService orderService;

@PostMapping(value = "/order")
public ResponseEntity<Boolean> createOrder(@RequestBody Order order){
return ResponseEntity.ok(orderService.createOrder(order));
}
}

@Slf4j
@Service
public class OrderService {

private static final Random R = new Random();

@Autowired
private MessageService messageService;

public Boolean createOrder(Order order) {
//模拟下单
try {
int ms = R.nextInt(50) + 50;
TimeUnit.MILLISECONDS.sleep(ms);
log.info("保存订单模拟耗时{}毫秒...", ms);
} catch (Exception e) {
//no-op
}
//记录下单总数
Metrics.counter("order.count", "order.channel", order.getChannel()).increment();
//发送消息
Message message = new Message();
message.setContent("模拟短信...");
message.setOrderId(order.getOrderId());
message.setUserId(order.getUserId());
messageService.sendMessage(message);
return true;
}
}

@Slf4j
@Service
public class MessageService implements InitializingBean {

private static final BlockingQueue<Message> QUEUE = new ArrayBlockingQueue<>(500);
private static BlockingQueue<Message> REAL_QUEUE;
private static final Executor EXECUTOR = Executors.newSingleThreadExecutor();
private static final Random R = new Random();

static {
REAL_QUEUE = Metrics.gauge("message.gauge", Tags.of("message.gauge", "message.queue.size"), QUEUE, Collection::size);
}

public void sendMessage(Message message) {
try {
REAL_QUEUE.put(message);
} catch (InterruptedException e) {
//no-op
}
}

@Override
public void afterPropertiesSet() throws Exception {
EXECUTOR.execute(() -> {
while (true) {
try {
Message message = REAL_QUEUE.take();
log.info("模拟发送短信,orderId:{},userId:{},内容:{},耗时:{}毫秒", message.getOrderId(), message.getUserId(),
message.getContent(), R.nextInt(50));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
});
}
}

//切面类
@Component
@Aspect
public class TimerAspect {

@Around(value = "execution(* club.throwable.smp.service.*Service.*(..))")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
Timer timer = Metrics.timer("method.cost.time", "method.name", method.getName());
ThrowableHolder holder = new ThrowableHolder();
Object result = timer.recordCallable(() -> {
try {
return joinPoint.proceed();
} catch (Throwable e) {
holder.throwable = e;
}
return null;
});
if (null != holder.throwable) {
throw holder.throwable;
}
return result;
}

private class ThrowableHolder {

Throwable throwable;
}
}

yaml的配置如下:

1
2
3
4
5
6
7
8
9
10
server:
port: 9091
management:
server:
port: 10091
endpoints:
web:
exposure:
include: '*'
base-path: /management

注意多看spring官方文档关于Actuator的详细描述,在SpringBoot-2.x之后,配置Web端点暴露的权限控制和1.x有很大的不同。总结一下就是:除了shutdown端点之外,其他端点默认都是开启支持的(这里仅仅是开启支持,并不是暴露为Web端点,端点必须暴露为Web端点才能被访问),禁用或者开启端点支持的配置方式如下:

1
management.endpoint.${端点ID}.enabled=true/false

可以查看actuator-api文档查看所有支持的端点的特性,这个是2.1.0.RELEASE版本的官方文档,不知道日后链接会不会挂掉。端点只开启支持,但是不暴露为Web端点,是无法通过http://{host}:{management.port}/{management.endpoints.web.base-path}/{endpointId}访问的。暴露监控端点为Web端点的配置是:

1
2
management.endpoints.web.exposure.include=info,health
management.endpoints.web.exposure.exclude=prometheus

management.endpoints.web.exposure.include用于指定暴露为Web端点的监控端点,指定多个的时候用英文逗号分隔。management.endpoints.web.exposure.exclude用于指定不暴露为Web端点的监控端点,指定多个的时候用英文逗号分隔。
management.endpoints.web.exposure.include默认指定的只有info和health两个端点,我们可以直接指定暴露所有的端点:management.endpoints.web.exposure.include=*,如果采用YAML配置,记得*要加单引号’*’。暴露所有Web监控端点是一件比较危险的事情,如果需要在生产环境这样做,请务必先确认http://{host}:{management.port}不能通过公网访问(也就是监控端点访问的端口只能通过内网访问,这样可以方便后面说到的Prometheus服务端通过此端口收集数据)。

Prometheus的安装和配置

Prometheus目前的最新版本是2.5,鉴于笔者没深入玩过Docker,这里还是直接下载它的压缩包解压安装。

1
2
3
wget https://github.com/prometheus/prometheus/releases/download/v2.5.0/prometheus-2.5.0.linux-amd64.tar.gz
tar xvfz prometheus-*.tar.gz
cd prometheus-*

先编辑解压出来的目录下的prometheus配置文件prometheus.yml,主要修改scrape_configs节点的属性:

1
2
3
4
5
6
7
8
9
10
11
scrape_configs:
# The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
- job_name: 'prometheus'

# metrics_path defaults to '/metrics'
# scheme defaults to 'http'.
# 这里配置需要拉取度量信息的URL路径,这里选择应用程序的prometheus端点
metrics_path: /management/prometheus
static_configs:
# 这里配置host和port
- targets: ['localhost:10091']

配置拉取度量数据的路径为localhost:10091/management/metrics,此前记得把前一节提到的应用在虚拟机中启动。接着启动Prometheus应用:

1
2
# 可选参数 --storage.tsdb.path=存储数据的路径,默认路径为./data
./prometheus --config.file=prometheus.yml

Prometheus引用的默认启动端口是9090,启动成功后,日志如下:

jvm-m-2.png

此时,访问http://${虚拟机host}:9090/targets就能看到当前Prometheus中执行的Job:

jvm-m-3.png

访问http://${虚拟机host}:9090/graph可以查找到我们定义的度量Meter和spring-boot-starter-actuator中已经定义好的一些关于JVM或者Tomcat的度量Meter。我们先对应用的/order接口进行调用,然后查看一下监控前面在应用中定义的order_count_totalmethod_cost_time_seconds_sum

jvm-m-4.png

jvm-m-5.png

可以看到,Meter的信息已经被收集和展示,但是显然不够详细和炫酷,这个时候就需要使用Grafana的UI做一下点缀。

Grafana的安装和使用

Grafana的安装过程如下:

1
2
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.3.4-1.x86_64.rpm 
sudo yum localinstall grafana-5.3.4-1.x86_64.rpm

安装完成后,通过命令service grafana-server start启动即可,默认的启动端口为3000,通过http://${host}:3000访问即可。初始的账号密码都为admin,权限是管理员权限。接着需要在Home面板添加一个数据源,目的是对接Prometheus服务端从而可以拉取它里面的度量数据。数据源添加面板如下:

jvm-m-6.png

其实就是指向Prometheus服务端的端口就可以了。接下来可以天马行空地添加需要的面板,就下单数量统计的指标,可以添加一个Graph的面板:

jvm-m-7.png

配置面板的时候,需要在基础(General)中指定Title:

jvm-m-9.png

接着比较重要的是Metrics的配置,需要指定数据源和Prometheus的查询语句:

jvm-m-8.png

最好参考一下Prometheus的官方文档,稍微学习一下它的查询语言PromQL的使用方式,一个面板可以支持多个PromQL查询。前面提到的两项是基本配置,其他配置项一般是图表展示的辅助或者预警等辅助功能,这里先不展开,可以取Grafana的官网挖掘一下使用方式。然后我们再调用一下下单接口,过一段时间,图表的数据就会自动更新和展示:

jvm-m-10.png

接着添加一下项目中使用的Timer的Meter,便于监控方法的执行时间,完成之后大致如下:

jvm-m-11.png

上面的面板虽然设计相当粗糙,但是基本功能已经实现。设计面板并不是一件容易的事,如果有需要可以从Github中搜索一下grafana dashboard关键字找现成的开源配置使用或者二次加工后使用。

小结

常言道:工欲善其事,必先利其器。Micrometer是JVM应用的一款相当优异的度量框架,它提供基于Tag和丰富的度量类型和API便于多维度地进行不同角度度量数据的统计,可以方便地接入Prometheus进行数据收集,使用Grafana的面板进行炫酷的展示,提供了天然的spring-boot体系支持。但是,在实际的业务代码中,度量类型Counter经常被滥用,一旦工具被不加思考地滥用,就反而会成为混乱或者毒瘤。因此,这篇文章就是对Micrometer中的各种Meter的使用场景基于个人的理解做了调研和分析,分享一下实战中的经验和踩坑经历。

以前写过的一篇文章:

参考资料:

(To be continue c-10-d n-e-20181102 最近有点忙,没办法经常更新 r-a-201918)