这是本节的多页打印视图。 点击此处打印.

返回本页常规视图.

SpringBoot 整合各大开源系列产品

记录SpringBoot接入邮件、zk、Prometheus监控、oss等与常见业务非强相关的系列教程

1 - Email

SpringBoot无障碍使用邮箱服务

1.1 - 1.邮件发送姿势介绍

邮件发送,在实际的项目开发中,可能用的不是特别多,如果没有特定的需求,相信也没有多少小伙伴会特意的去关注,那么如果现在我们希望针对项目做一个异常的报警系统,当出现异常的时候,可以向指定的小伙伴发送邮件提醒,那么让我们来实现这个功能,可以怎么办呢?

这里介绍一下如何使用SpringBoot封装好的MailSender来实现邮件发送

I. 项目环境

1. 项目依赖

本项目借助SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA进行开发

开一个web服务用于测试

<dependencies>
    <!-- 邮件发送的核心依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-mail</artifactId>
    </dependency>
    <!-- 适用于html模板形式的邮件发送,借助freemarker来实现html模板渲染 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-freemarker</artifactId>
    </dependency>
</dependencies>

2. 配置

在开始之前,我们需要先准备一个用于发送邮件的账号,比如我这里使用163的邮箱来发送邮件,需要先到邮箱提供商哪里获取授权码,具体如何获取这个东西,不同的邮箱姿势有些不同,各位小伙伴根据自己的实际情况,搜索一下,相信很快就能get到

这里简单介绍下网易邮箱的获取方式

接下来设置发送邮件相关的配置信息,配置文件application.yml

spring:
  #邮箱配置
  mail:
    host: smtp.163.com
    from: xhhuiblog@163.com
    # 使用自己的发送方用户名 + 授权码填充
    username:
    password:
    default-encoding: UTF-8
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true

II. 邮件发送

接下来进入正题,我们将从简单基础的文本邮件发送开始,逐渐介绍如何添加附件,使用漂亮的html模板等

1. 简单文本邮件发送

我们这里直接使用JavaMailSender来发送一个基础的文本邮件

@Service
public class MailDemo {
    @Autowired
    private JavaMailSender javaMailSender;

    @Value("${spring.mail.from:xhhuiblog@163.com}")
    private String from;

    private void basicSend() {
        SimpleMailMessage simpleMailMessage = new SimpleMailMessage();
        //邮件发送人
        simpleMailMessage.setFrom(from);
        //邮件接收人,可以是多个,参数为可变参数
        simpleMailMessage.setTo("bangzewu@126.com");
        //邮件主题,也就是标题
        simpleMailMessage.setSubject("SpringBoot测试邮件发送");
        //邮件内容
        simpleMailMessage.setText("简单的邮件正文");

        javaMailSender.send(simpleMailMessage);
    }
}
  • JavaMailSender: 直接作为一个Spring 的bean对象使用
  • SimpleMailMessage:简单的邮件对象,里面有一些邮件发送时,关联的基础信息
    • from: 发送方
    • replyTo: 邮件回复的收件人
    • to: 收件人
    • cc: 抄送
    • bcc: 密送
    • subject: 主题,也就是邮件标题
    • text: 邮件正文,文本格式
    • date: 邮件发送时间

2. html发送

对于简单的文本邮件发送,用上面的基本就够了,如果我们希望邮件的内容更美观一点的话,可以借助HTML来实现排版

区别于上面的SimpleMailMessage, 这里使用的是MimeMessage,来实现html内容发送

使用姿势与上面相比差不多,无非就是正文变成了html文本罢了

/**
 * 发送html
 */
public void sendHtml() throws MessagingException {
    MimeMessage mimeMailMessage = javaMailSender.createMimeMessage();
    MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMailMessage, true);
    mimeMessageHelper.setFrom(from);
    mimeMessageHelper.setTo("bangzewu@126.com");
    mimeMessageHelper.setSubject("SpringBoot测试邮件发送");

    //邮件内容
    mimeMessageHelper.setText("<h1>Hello World</h1> <br/> " +
            "<div> 欢迎点击 <a href=\"https://blog.hhui.top\">一灰灰博文地址</a><br/>" +
            " <img width=\"200px\" height=\"200px\" src=\"https://blog.hhui.top/hexblog/imgs/info/wx.jpg\"/>" +
            "</div>", true);

    javaMailSender.send(mimeMailMessage);
}

重点注意

  • 注意上面的setText方法的第二个参数,必须有,且为true,否则会当成文本内容发送

3. 添加附件

邮件中添加附件,我们自己写邮件的时候可以直接选择附件上传,那么代码的实现方式又有什么区别呢?

/**
 * 发送附件
 */
public void sendWithFile() throws MessagingException, IOException {
    MimeMessage mimeMailMessage = javaMailSender.createMimeMessage();
    MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMailMessage, true);
    mimeMessageHelper.setFrom(from);
    mimeMessageHelper.setTo("bangzewu@126.com");
    mimeMessageHelper.setSubject("SpringBoot测试邮件发送");

    mimeMessageHelper.setText("<h1>Hello World</h1> <br/> " +
            "<div> 欢迎点击 <a href=\"https://blog.hhui.top\">一灰灰博文地址</a><br/>" +
            " <img width=\"200px\" height=\"200px\" src=\"https://blog.hhui.top/hexblog/imgs/info/wx.jpg\"/>" +
            "</div>");

    String url = "https://blog.hhui.top/hexblog/imgs/info/wx.jpg";
    URL imgUrl = new URL(url);
    mimeMessageHelper.addAttachment("img.jpg", imgUrl::openStream);

    javaMailSender.send(mimeMailMessage);
}

注意上面的实现,与前面差别不大,关键点在于attachment附件,上面的实现是在附件中添加一个图片,为了简单起见,图片是直接从网络下载的,然后将Stream作为传参

4. Freemaker模板

上面的html发送,会发现需要我们自己来组装html正文,这个操作可能就不是很美好了,借助页面渲染引擎来实现邮件模板支持,可以说是一个比较常见的方案了,这里简单介绍下Freemaker的实现姿势,至于themlaf, beef或者jsp啥的,都没有太大的区别

首先写一个邮件模板 resources/template/mail.ftl

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="SpringBoot thymeleaf"/>
    <meta name="author" content="YiHui"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>邮件模板</title>
</head>
<style>
    .title {
        color: #c00;
        font-weight: normal;
        font-size: 2em;
    }

    .content {
        color: darkblue;
        font-size: 1.2em;
    }

    .sign {
        color: lightgray;
        font-size: 0.8em;
        font-style: italic;
    }
</style>
<body>

<div>
    <div class="title">${title}</div>
    <div class="content">${content}</div>
</div>
</body>
</html>

上面的模板中,定义了两个变量,一个title,一个content,这个就是我们需要替换的值

接下来是邮件发送实例

import freemarker.template.Configuration;

@Autowired
private Configuration configuration;

/**
 * freemarker 模板
 */
public void freeMakerTemplate() throws MessagingException, IOException, TemplateException {
    MimeMessage mimeMailMessage = javaMailSender.createMimeMessage();
    MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMailMessage, true);
    mimeMessageHelper.setFrom(from);
    mimeMessageHelper.setTo("bangzewu@126.com");
    mimeMessageHelper.setSubject("SpringBoot测试邮件发送");

    Map<String, Object> map = new HashMap<>();
    map.put("title", "邮件标题");
    map.put("content", "邮件正文");
    String text = FreeMarkerTemplateUtils.processTemplateIntoString(configuration.getTemplate("mail.ftl"), map);
    mimeMessageHelper.setText(text, true);

    String url = "https://blog.hhui.top/hexblog/imgs/info/wx.jpg";
    URL imgUrl = new URL(url);
    mimeMessageHelper.addAttachment("img.jpg", imgUrl::openStream);

    javaMailSender.send(mimeMailMessage);
}

注意上面的实现,关键点就利用FreeMarkerTemplateUtils来实现模板的渲染,输出html正文,因此如果想使用其他的模板渲染引擎,就是改这里即可

5. 测试与小结

最后简单的调用一下上面的实现,看下邮件是否可以发送成功

本篇博文介绍了一下如何发送邮件,并针对简单的文本邮件,html正文,附件等不同的给出了实例;整体看下来使用姿势不难,不过邮件的几个术语可以了解一下

  • to: 接收人,就是邮件发送的目标群众
  • cc: 抄送,一般来讲抄送的名单,只是让他感知到有这封邮件,属于周知对象
  • bcc: 密送,与上面两个不一样,接收人和抄送人不知道密送给谁了,这就是最大的区别,说实话这个玩意我从没用过

接下来一篇博文,将介绍一下如何将log日志与邮件发送关联起来,当出现异常的时候,邮件发送给开发者

III. 不能错过的源码和相关知识点

0. 项目

1.2 - 2.实战:基于异常日志的邮件报警

相信所有奋斗在一线的小伙伴,会很关心自己的系统的运行情况,一般来说,基础设施齐全一点的公司都会有完善的报警方案,那么如果我们是一个小公司呢,不能因为基础设施没有,就失去对象的感知能力吧;如果我们的系统大量异常却不能实时的触达给我们,那么也就只会有一个结果–杀个程序猿祭天

本文简单的介绍一种实现思路,基于error日志来实现邮件的报警方案

I. 项目环境

1. 项目依赖

本项目借助SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA进行开发

开一个web服务用于测试

<dependencies>
    <!-- 邮件发送的核心依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-mail</artifactId>
    </dependency>
</dependencies>

2. 配置

邮件相关配置如下,注意使用自己的用户名 + 授权码填充下面缺失的配置

spring:
  #邮箱配置
  mail:
    host: smtp.163.com
    from: xhhuiblog@163.com
    # 使用自己的发送方用户名 + 授权码填充
    username:
    password:
    default-encoding: UTF-8
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true

II. 异常日志的邮件预警

1. 设计思路

接下来这个方案的主要出发点在于,当程序出现大量的异常,表明应用多半出现了问题,需要立马发送给项目owner

要实现这个方案,关键点就在于异常出现的感知与上报

  • 异常的捕获,并输出日志(这个感觉属于标配了吧,别告诉我现在还有应用不输出日志文件的…)
    • 对于这个感知,借助logback的扩展机制,可以实现,后面介绍
  • 异常上报:邮件发送

关于email的使用姿势,推荐参考博文 SpringBoot 系列之邮件发送姿势介绍

2. 自定义appender

定义一个用于错误发送的Appender,如下

public class MailUtil extends AppenderBase<ILoggingEvent> {

    public static void sendMail(String title, String context) {
        SimpleMailMessage simpleMailMessage = new SimpleMailMessage();
        //邮件发送人
        simpleMailMessage.setFrom(ContextUtil.getEnvironment().getProperty("spring.mail.from", "bangzewu@126.com"));
        //邮件接收人,可以是多个
        simpleMailMessage.setTo("bangzewu@126.com");
        //邮件主题
        simpleMailMessage.setSubject(title);
        //邮件内容
        simpleMailMessage.setText(context);

        JavaMailSender javaMailSender = ContextUtil.getApplicationContext().getBean(JavaMailSender.class);
        javaMailSender.send(simpleMailMessage);
    }

    private static final long INTERVAL = 10 * 1000 * 60;
    private long lastAlarmTime = 0;

    @Override
    protected void append(ILoggingEvent iLoggingEvent) {
        if (canAlarm()) {
            sendMail(iLoggingEvent.getLoggerName(), iLoggingEvent.getFormattedMessage());
        }
    }

    private boolean canAlarm() {
        // 做一个简单的频率过滤
        long now = System.currentTimeMillis();
        if (now - lastAlarmTime >= INTERVAL) {
            lastAlarmTime = now;
            return true;
        } else {
            return false;
        }
    }
}

3. Spring容器

上面的邮件发送中,需要使用JavaMailSender,写一个简单的SpringContext工具类,用于获取Bean/Propertiy

@Component
public class ContextUtil implements ApplicationContextAware, EnvironmentAware {

    private static ApplicationContext applicationContext;

    private static Environment environment;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        ContextUtil.applicationContext = applicationContext;
    }

    @Override
    public void setEnvironment(Environment environment) {
        ContextUtil.environment = environment;
    }

    public static ApplicationContext getApplicationContext() {
        return applicationContext;
    }

    public static Environment getEnvironment() {
        return environment;
    }
}

4. logback配置

接下来就是在日志配置中,使用我们上面定义的Appender

logback-spring.xml文件内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d [%t] %-5level %logger{36}.%M\(%file:%line\) - %msg%n</pattern>
            <!-- 控制台也要使用UTF-8,不要使用GBK,否则会中文乱码 -->
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <appender name="errorAlarm" class="com.git.hui.demo.mail.util.MailUtil">
        <!--如果只是想要 Error 级别的日志,那么需要过滤一下,默认是 info 级别的,ThresholdFilter-->
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
    </appender>


    <!-- 指定项目中某个包,当有日志操作行为时的日志记录级别 -->
    <!-- 级别依次为【从高到低】:FATAL > ERROR > WARN > INFO > DEBUG > TRACE  -->
    <!-- additivity=false 表示匹配之后,不再继续传递给其他的logger-->
    <logger name="com.git.hui" level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="errorAlarm"/>
    </logger>

    <!-- 控制台输出日志级别 -->
    <root level="INFO">
        <appender-ref ref="STDOUT"/>
    </root>
</configuration>

5. 测试demo

接下来演示一下,是否可以达到我们的预期

@Slf4j
@RestController
@SpringBootApplication
public class Application {

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

    @GetMapping("div")
    public String div(int a, int b) {
        try {
            return String.valueOf(a / b);
        } catch (Exception e) {
            log.error("div error! {}/{}", a, b, e);
            return "some error!";
        }
    }
}

5.小结

本篇博文主要提供了一个思路,借助logback的扩展机制,来实现错误日志与预警邮件绑定,实现一个简单的应用异常监控

上面这个实现只算是一个雏形,算是抛砖引玉,有更多可以丰富的细节,比如

  • 飞书/钉钉通知(借助飞书钉钉的机器来报警,相比较于邮件感知性更高)
  • 根据异常类型,做预警的区分
  • 更高级的频率限制等

在这里推荐一个我之前开源的预警系统,可以实现灵活预警方案配置,频率限制,重要性升级等

III. 不能错过的源码和相关知识点

0. 项目

推荐关联博文

2 - Prometheus

接入Prometheus,搭建应用监控体系

2.1 - 1.Prometheus实现应用监控

1. prometheus 安装

教程文档: https://www.prometheus.wang/quickstart/install-prometheus-server.html

1.1 什么是Prometheus

普罗米修斯:Prometheus是一个开放性的监控解决方案,用户可以非常方便的安装和使用Prometheus并且能够非常方便的对其进行扩展

下面将实现一个SpringBoot应用接入Prometheus的全过程

1.2 安装

Linux 安装

下载本地安装启动

wget https://github.com/prometheus/prometheus/releases/download/v2.26.0/prometheus-2.26.0.linux-amd64.tar.gz

tar -zxvf prometheus-2.26.0.linux-amd64.tar.gz
cd prometheus-2.26.0.linux-amd64

# 启动命令
./prometheus

启动完毕之后,本地访问 http://127.0.0.1:9090/graph 可以看到默认提供的界面

2. SpringBoot应用接入

我们演示的SpringBoot为2.0+,因此直接选择io.micrometer 的依赖包来实现;更低版本的不能使用这种姿势,可以直接使用官方提供的client来实现;这里不进行扩展

2.1 依赖配置

借助SpringBoot的actuator来提供扩展端点(所以本文采用的是Prometheus的拉工作模式)

SpringBoot版本为 2.2.1.RELEASE

核心依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
</dependencies>

yaml配置文件,需要指定Prometheus相关的参数,一个demo如下

spring:
  application:
    name: prometheus-example
management:
  endpoints:
    web:
      exposure:
        include: "*"
  metrics:
    tags:
      application: ${spring.application.name}

注意

  • management.endpoints.web.exposure.include 这里指定所有的web接口都会上报
  • metrics.tags.application 这个应用所有上报的metrics 都会带上application这个标签

上面配置完毕之后,会提供一个 /actuator/prometheus的端点,供prometheus来拉取Metrics信息

2.2 应用启动

对于SpringBoot而言,此时就不需要额外做什么,就可以实现应用的基本信息上报了

一个简单的demo如下

@RestController
@SpringBootApplication
public class Application {
    private Random random = new Random();
	
    // 一个用于演示的http接口
    @GetMapping(path = "hello")
    public String hello(String name) {
        int sleep = random.nextInt(200);
        try {
            Thread.sleep(sleep);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "hello sleep: " + sleep + " for " + name;
    }

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

    // 注意,这个是注册的核心代码块
    @Bean
    MeterRegistryCustomizer<MeterRegistry> configurer(@Value("${spring.application.name}") String applicationName) {
        return (registry) -> registry.config().commonTags("application", applicationName);
    }
}

到此,springboot应用的监控就算是完成了;接下来配置一下prometheus的服务端

3. prometheus 配置与实测

在前面下载的包下面,有一个配置文件 prometheus.yml,新增一个Job

  - job_name: 'prometheus-example'
  	# 抓取频率
    scrape_interval: 5s
    # 抓取的端点
    metrics_path: '/actuator/prometheus'
    static_configs:
    	# 目标机器,数组,也就是说支持集群拉取
      - targets: ['127.0.0.1:8080']

修改配置之后,需要重启一下,当服务启动之后,可以在控制台上我们的应用信息

接下来访问Graph,选择metric: http_server_requests_seconds_count 可以看到一条抓起metric的记录

前面我们定义了一个Controller,接下来简单访问几次,然后再看一下,会发现多一条记录

这些数据由框架层直接集成,实现REST接口的相关信息上报,借助这个metric,我们可以实现qps的统计

3.1 qps统计

sum(rate(http_server_requests_seconds_count{application="prometheus-example"}[10s]))

  • rate: 用于统计增长趋势,要求上报的Metric为Counter类型(只增不减)
  • irate: 与rate相似,区别在于rate统计的是一段时间内的平均增长速率,无法反应这个时间窗口内的突发情况(即瞬时高峰),irate通过区间向量中最后两个样本数据来计算增长速率,但是当选用的区间范围较大时,可能造成不小的偏差
  • sum: 求和,适用于统计场景

更多内置函数,可以参考: PromQL内置函数

3.2 耗时统计

除了qps,另外一个经常关注的指标就是rt了,如上面接口的平均rt,通过两个Metric的组合来实现

sum(rate(http_server_requests_seconds_sum{application="prometheus-example"}[10s])) / sum(rate(http_server_requests_seconds_count{application="prometheus-example"}[10s]))

将sum聚合去掉之后,则可以看到各接口的访问情况

4. Grafana 大盘配置

面板监控,还是Grafana的比较强大,特别是grafana本身提供了很多模板可以直接导入

安装可以参考: 210318-linux grafana大盘接入mysql

4.1 大盘配置

grafana启动之后,配置数据源Promethues

接下来配置SpringBoot的应用配置面板,可以直接使用现成的模板,比如 12856

导入完毕之后,大盘展示如下

可以看一下请求耗时的统计promql

4.2 大盘模板哪里找

如何找直接可用的大盘呢?

5. 小结

上面整个流程走下来会发现SpringBoot项目接入Prometheus成本很低,基本上没有太多的编码工作,就可以配置给功能集全的监控大盘,简直不要太嗨

高度封装的便捷性再这里体现得非常突出了,但是搞完之后,再回想一下,我get到了什么?

好像什么都没get到,如果我的服务只提供grpc/dubbo接口,现在假设让我们接入监控,好像还是抓瞎,这该怎么玩

II. 其他

0. 项目

2.2 - 2.自定义埋点上报

之前介绍了一篇SpringBoot集成Prometheus实现数据上报的博文,在前面一篇博文中,更多的是一个SpringBoot应用如何最小成本的接入Prometheus,并结合Grafana配置一个完整的应用监控大盘

有看过前文的小伙伴可能知晓,SpringBoot接入Prometheus之后,基本上不用做额外的开发,就已经实现了我们关心的JVM情况、GC情况、HTTP调用请求等信息,然而在实际的业务开发过程中,我们总会遇到一些需要手动上报的场景,那么我们可以怎么处理呢?

本文的核心知识点:

  • 通过一个实例演示SpringBoot应用,如何实现自定义的数据上报

上篇博文: SpringBoot整合Prometheus实现应用监控

I. 项目环境搭建

本文演示的项目主要为SpringBoot2.2.1版本,更高的版本使用姿势没有太大的区别,至于1.x版本的不确保可行(因为我并没有测试)

1.依赖

pom依赖,主要是下面几个包

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
</dependencies>

2. 配置信息

其次是配置文件,注册下Prometheus的相关信息

spring:
  application:
    name: prometheus-example
management:
  endpoints:
    web:
      exposure:
        include: "*"
  metrics:
    tags:
      application: ${spring.application.name}

上面配置中,有两个关键信息,前面博文也有介绍,这里简单说明

  • management.endpoints.web.exposure.include 这里指定所有的web接口都会上报
  • metrics.tags.application 这个应用所有上报的metrics 都会带上application这个标签

配置完毕之后,会提供一个 /actuator/prometheus的端点,供prometheus来拉取Metrics信息

II. 自定义上报

假设我们现在想自己上报http请求的相关信息,当前计划采集下面几个信息

  • 总的请求数:采用Counter
  • 当前正在处理的请求数:采用Gauge
  • 请求耗时直方图: Histogram

1. Prometheus Metric封装

基于上面的分析,我们这里实现了三种常见的Metric信息上报,这里提供一个统一的封装类,用于获取对应的Metric类型

package com.git.hui.boot.prometheus.interceptor;

import io.prometheus.client.CollectorRegistry;
import io.prometheus.client.Counter;
import io.prometheus.client.Gauge;
import io.prometheus.client.Histogram;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

/**
 * @author yihui
 * @date 2021/11/09
 */
@Component
public class PrometheusComponent implements ApplicationContextAware {
    private static PrometheusComponent instance;


    /**
     * 请求总数
     */
    private Counter reqCounter;

    /**
     * 正在请求的http数量
     */
    private Gauge duringReqGauge;

    /**
     * 直方图,请求分布情况
     */
    private Histogram reqLatencyHistogram;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        instance = this;
        CollectorRegistry collectorRegistry = applicationContext.getBean(CollectorRegistry.class);
        // 这里指定SpringBoot容器的CollectorRegistry,如果使用默认的会导致无法收集
        reqCounter = Counter.build().name("demo_rest_req_total").labelNames("path", "method", "code")
                .help("总的请求计数").register(collectorRegistry);
        duringReqGauge = Gauge.build()
                .name("demo_rest_inprogress_req").labelNames("path", "method")
                .help("正在处理的请求数").register(collectorRegistry);
        reqLatencyHistogram = Histogram.build().labelNames("path", "method", "code")
                .name("demo_rest_requests_latency_seconds_histogram").help("请求耗时分布")
                .register(collectorRegistry);
    }

    public static PrometheusComponent getInstance() {
        return instance;
    }

    public Counter counter() {
        return reqCounter;
    }

    public Gauge gauge() {
        return duringReqGauge;
    }

    public Histogram histogram() {
        return reqLatencyHistogram;
    }
}

注意上面的setApplicationContext()的方法实现逻辑,其中在创建Counter/Gauge/Histogram时,使用的是simpleclient包中提供的最基础的用法,并不是micrometer的封装方式,后面一篇博文会介绍到两种的差异性

上面实现的特点在于,创建Metric时,就已经定义好了label标签,这里定义了

  • path: 请求url路径
  • method: http方法, get/post
  • code: 状态码,表示请求成功还是异常

2. 拦截器实现自定义信息采集上报

接下来我们实现一个自定义的拦截器,拦截所有的http请求,然后上报关键信息

public class PrometheusInterceptor extends HandlerInterceptorAdapter {

    private ThreadLocal<Histogram.Timer> timerThreadLocal = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 正在处理的请求量
        PrometheusComponent.getInstance().gauge().labels(request.getRequestURI(), request.getMethod()).inc();

        timerThreadLocal.set(PrometheusComponent.getInstance().histogram()
                .labels(request.getRequestURI(), request.getMethod(), String.valueOf(response.getStatus()))
                .startTimer());
        return super.preHandle(request, response, handler);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String uri = request.getRequestURI();
        String method = request.getMethod();
        int status = response.getStatus();
        // count 请求计数,标签分别为 请求路径,请求方法,response http code
        // 请求应用总量:  sum(demo_rest_req_total)
        // 每秒http请求量: sum(rate(demo_rest_req_total[1m])
        // 请求topk的url:  topk(10, sum(demo_rest_req_total) by (path))
        PrometheusComponent.getInstance().counter().labels(uri, method, String.valueOf(status)).inc();

        // 请求完毕,计数器-1
        PrometheusComponent.getInstance().gauge().labels(uri, method).dec();

        // 直方图统计
        Histogram.Timer timer = timerThreadLocal.get();
        if (timer != null) {
            timer.observeDuration();
            timerThreadLocal.remove();
        }
        super.afterCompletion(request, response, handler, ex);
    }
}

对于拦截器的知识点这里不进行展开,有兴趣的小伙伴可以查看 SpringBoot系列Web篇之拦截器Interceptor使用姿势介绍

这里我们主要关心的就两点

  • 执行之前(preHandle): gauge计数+1,开始计时
  • 执行之后 (afterCompletion): guage计数-1,counter计数+1,计时收集

3. 测试

最后我们需要注册上面的拦截器,并写个demo进行测试一下

@RestController
@SpringBootApplication
public class Application implements WebMvcConfigurer {
    private Random random = new Random();

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new PrometheusInterceptor()).addPathPatterns("/**");
    }

    @GetMapping(path = "hello")
    public String hello(String name) {
        int sleep = random.nextInt(200);
        try {
            Thread.sleep(sleep);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "hello sleep: " + sleep + " for " + name;
    }

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

    @Bean
    MeterRegistryCustomizer<MeterRegistry> configurer(@Value("${spring.application.name}") String applicationName) {
        return (registry) -> registry.config().commonTags("application", applicationName);
    }

}

应用启动之后,访问几次hello的http接口,然后在查看一下metric信息,看是否有我们刚才上报的数据

4. 小结

这一篇博文算是上一篇的补全,若我们希望自定义上报一些信息,可以使用上面这种方式来支持

当然,上报并不代表结束,接下来配置大盘等信息也非常的关键,特别是直方图如何配置Grafana?怎么查看请求的耗时分布情况,就由下文来介绍了

III. 不能错过的源码和相关知识点

0. 项目

1. 微信公众号: 一灰灰Blog

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

一灰灰blog

2.3 - 3.自定义埋点姿势二

关于Prometheus的自定义埋点,前一篇博文已经介绍了,为啥这里又来一次?

看过前文的小伙伴可能会知道,之前采用的simpleclient包定义的几个metric来实现的,实际上有更简单方便的姿势,那就是直接借助MeterRegistry来创建Metric来实现数据采集即可

相比较于前文的实现,总的来说简易程度可见一般,上篇文章可以点击下文查看

I. 自定义上报

依然是搭建一个基础项目工程,本文演示的项目主要为SpringBoot2.2.1版本,更高的版本使用姿势没有太大的区别,至于1.x版本的不确保可行(因为我并没有测试)

1.依赖

pom依赖,主要是下面几个包

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
</dependencies>

2. 配置信息

其次是配置文件,注册下Prometheus的相关信息

spring:
  application:
    name: prometheus-example
management:
  endpoints:
    web:
      exposure:
        include: "*"
  metrics:
    tags:
      application: ${spring.application.name}

上面配置中,有两个关键信息,前面博文也有介绍,这里简单说明

  • management.endpoints.web.exposure.include 这里指定所有的web接口都会上报
  • metrics.tags.application 这个应用所有上报的metrics 都会带上application这个标签

配置完毕之后,会提供一个 /actuator/prometheus的端点,供prometheus来拉取Metrics信息

3. 自定义拦截器实现采集上报

实现一个基础的拦截器,用来拦截所有的http请求,然后收集请求信息上报

public class MetricInterceptor extends HandlerInterceptorAdapter {
    @Autowired
    private MeterRegistry meterRegistry;
    private ThreadLocal<Timer.Sample> threadLocal = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 总计数 + 1
        meterRegistry.counter("micro_req_total", Tags.of("url", request.getRequestURI(), "method", request.getMethod())).increment();
        // 处理中计数 +1
        meterRegistry.gauge("micro_process_req", Tags.of("url", request.getRequestURI(), "method", request.getMethod()), 1);

        Timer.Sample sample = Timer.start();
        threadLocal.set(sample);
        return super.preHandle(request, response, handler);
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        try {
            super.postHandle(request, response, handler, modelAndView);
        } finally {
            meterRegistry.gauge("micro_process_req", Tags.of("url", request.getRequestURI(), "method", request.getMethod()), -1);
            //  Timer timer = meterRegistry.timer("micro_req_histogram", Tags.of("url", request.getRequestURI(), "method", request.getMethod(), "code", String.valueOf(response.getStatus())));
            Timer timer = Timer.builder("micro_req_histogram").minimumExpectedValue(Duration.ofMillis(1)).maximumExpectedValue(Duration.ofMinutes(3))
                    .sla(Duration.ofMillis(10), Duration.ofMillis(50), Duration.ofMillis(100), Duration.ofMillis(300), Duration.ofMillis(1000))
                    .tags(Tags.of("url", request.getRequestURI(), "method", request.getMethod(), "code", String.valueOf(response.getStatus())))
                    .register(meterRegistry);
            threadLocal.get().stop(timer);
            threadLocal.remove();
        }
    }
}

注意上面的三种Metric的创建方式

  • Counter: 直接使用 meterRegistry.counter()来创建metric并实现计数+1
    • 传参中,Tags组成的就是propmetheus中定义的label,kv格式,第一个参数用来定义MetricName
  • Gauge: 使用姿势与上面基本相同,不过需要注意计数的加减是直接在传参中
  • Histogram: 它的使用姿势就需要特别注意下了,在preHander中定义的是 Timer.Sampler对象,在 postHandler中实现的数据采集

上面短短一点代码,就实现了一个简单的自定义信息上报;接下来就是注册拦截器了

4. 注册并测试

拦截器依赖了Spring的bean对象,因此需要将它定义为bean对象

@RestController
@SpringBootApplication
public class Application implements WebMvcConfigurer {
    private Random random = new Random();

    @Bean
    public MetricInterceptor metricInterceptor() {
        return new MetricInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(metricInterceptor()).addPathPatterns("/**");
    }

    @GetMapping(path = "hello")
    public String hello(String name) {
        int sleep = random.nextInt(200);
        try {
            Thread.sleep(sleep);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "hello sleep: " + sleep + " for " + name;
    }

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

基于此一个简单的自定义采集上报就完成了;项目启动之后,通过访问采集端点查看是否有数据上报

最后小结一下,虽然SpringBoot可以非常方便的接入prometheus来采集一些常见的指标,但是当我们有自定义上报指标的需求时,直接使用MeterRegistry来收集信息,创建Metric是个不错的选择,通常我们选择的三种类型作用如下

  • 总的请求数:采用Counter
  • 当前正在处理的请求数:采用Gauge
  • 请求耗时直方图: Histogram

II. 不能错过的源码和相关知识点

0. 项目

1. 微信公众号: 一灰灰Blog

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

一灰灰blog

2.4 - 4.基于AOP实现埋点采集上报

前面几篇文章介绍了SpringBoot继承Prometheus实现埋点上报,基本上可以非常简单的实现采样收集,对于由SpringBoot搭建的web应用,甚至是可以说是引入依赖,简单的配置下,剩下的啥也不用管,就可以配置一个REST应用的监控大盘

接下来我们通过AOP的方式,来定义一个自定义数据采集的功能,用于实现一些上面覆盖不到的场景(如应用内的定时任务执行情况,三方接口请求监控等)

I. 方案确定与环境搭建

1.metric选择

通过前面的几篇文章,至少我们会了解到Prometheus的四种Metric,对于自定义的数据采集,根据最终希望监控的指标(每秒请求数 qps, 响应耗时 rt, 可用率 sla, 请求分布),我们这里选择Histogram

  • 通过histogram指标中的 count 值来计算qps
  • 通过 sum / count 来计算rt
  • 通过成功数 / 总请求数计算可用率sla
  • 通过histogram的bucket分布来查看请求分布

2.项目依赖

本项目借助SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA进行开发

其核心pom依赖,主要是下面几个包

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-aop</artifactId>
        <version>5.2.1.RELEASE</version>
        <scope>compile</scope>
    </dependency>
</dependencies>

3. 配置信息

其次是配置文件,注册下Prometheus的相关信息

spring:
  application:
    name: spring-prometheus-metric
management:
  endpoints:
    web:
      exposure:
        include: "*"
  metrics:
    tags:
      application: ${spring.application.name}

上面配置中,有两个关键信息,前面博文也有介绍,这里简单说明

  • management.endpoints.web.exposure.include 这里指定所有的web接口都会上报
  • metrics.tags.application 这个应用所有上报的metrics 都会带上application这个标签

配置完毕之后,会提供一个 /actuator/prometheus的端点,供prometheus来拉取Metrics信息

II. AOP切面实现埋点上报

1. 切面实现类

通过切面来拦截目标类的执行,选择三个关键指标

  • service: 表示具体执行的类
  • method:执行的方法
  • err: true 表示执行异常/else 表示执行正常

直接使用Histogram来实现数据采集上报,直接使用前面博文* 【中间件】Prometheus自定义埋点姿势二 | 一灰灰Blog 使用姿势即可

@Aspect
@Component
public class MetricAop {
    @Autowired
    private MeterRegistry meterRegistry;

    @Pointcut("execution(public * com.git.hui.demo.prometheus.service.*.*(..))")
    public void point() {
    }

    /**
     * 拦截Service共有方法,上报接口执行情况到Prometheus
     *
     * @param joinPoint
     * @return
     * @throws Throwable
     */
    @Around("point()")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        String service = joinPoint.getTarget().getClass().getSimpleName();
        String method = joinPoint.getSignature().getName();

        Timer.Sample sample = Timer.start();
        boolean hasError = false;
        try {
            return joinPoint.proceed();
        } catch (Throwable e) {
            hasError = true;
            throw e;
        } finally {
            Timer timer = Timer.builder("micro_service_histogram")
                    .minimumExpectedValue(Duration.ofMillis(1))
                    .maximumExpectedValue(Duration.ofMinutes(3))
                    .sla(Duration.ofMillis(10), Duration.ofMillis(50), Duration.ofMillis(100), Duration.ofMillis(300), Duration.ofMillis(1000))
                    .tags(Tags.of("service", service, "method", method, "err", String.valueOf(hasError)))
                    .register(meterRegistry);
            sample.stop(timer);
        }
    }
}

2. 测试方法

这里写两个简单的Service类,作为收集采样的目标

@Service
public class DemoService {
    private Random random = new Random();

    // 用于控制方法的执行耗时
    private void trySleep() {
        try {
            Thread.sleep(random.nextInt(50));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public int add(int a, int b) {
        trySleep();
        return a + b;
    }

    public int sub(int a, int b) {
        trySleep();
        return a - b;
    }

    public int divide(int a, int b) {
        trySleep();
        return a / b;
    }
}


@Service
public class HelloService {
    private Random random = new Random();

    private void trySleep() {
        try {
            Thread.sleep(random.nextInt(100) + 100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public String hello(String name) {
        trySleep();
        return "hello: " + name;
    }

    public String welcome(String name) {
        trySleep();
        return "welcome: " + name;
    }
}

然后我们通过定时任务来不断的访问上面的服务方法,用来模拟请求场景

@Autowired
private DemoService demoService;
@Autowired
private HelloService helloService;
private Random random = new Random();

private void call(Runnable runnable, CountDownLatch latch) {
    new Thread(() -> {
        try {
            runnable.run();
        } finally {
            latch.countDown();
        }
    }).start();
}

// 100ms请求一次,基本上接口的qps在10左右
@Async("main")
@Scheduled(fixedDelay = 100)
public void doDemoCall() {
    CountDownLatch latch = new CountDownLatch(3);
    call(() -> demoService.add(random.nextInt(10), random.nextInt(30)), latch);
    call(() -> demoService.sub(random.nextInt(10), random.nextInt(30)), latch);
    // 注意这个divide,分母是可能存在为0的哦
    call(() -> demoService.divide(random.nextInt(10), random.nextInt(30)), latch);
    latch.countDown();
}

@Async
@Scheduled(fixedDelay = 100)
public void doHelloCall() {
    CountDownLatch latch = new CountDownLatch(2);
    call(() -> helloService.hello("YiHui " + random.nextInt(30)), latch);
    call(() -> helloService.welcome("YiHui " + random.nextInt(30)), latch);
    latch.countDown();
}

3. 启动类

最后就是在启动类中注册一下MeterRegistryCustomizer,为所有的metric携带上application标签

@EnableScheduling
@SpringBootApplication
public class Application {

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

    @Bean
    MeterRegistryCustomizer<MeterRegistry> configurer(@Value("${spring.application.name}") String applicationName) {
        return (registry) -> {
            registry.config().commonTags("application", applicationName);
            MetricWrapper.setMeterRegistry(registry);
        };
    }
}

应用启动之后,就可以通过http://m-162d9nnes031u:8080/actuator/prometheus来查看收集的采样信息了,如

4. 业务大盘配置

上面基本即实现了一个基于aop的采样收集,接下来重点就是如何将这些收集上来的数据,可视化配置起来

借助Grafana无疑是个比较推荐的case,下一篇博文将详细介绍如何针对上面收集的信息进行友好的配置,欢迎有兴趣的小伙伴关注吐槽

III. 不能错过的源码和相关知识点

0. 项目源码

系列博文:

1. 微信公众号: 一灰灰Blog

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

一灰灰blog

2.5 - 5.Prometheus大盘配置实战

借助Grafana来实现大盘配置,关于Grafana的启用配置,这里就不详细说明,有兴趣的可以查看前文 * 【中间件】Prometheus实现应用监控 | 一灰灰Blog

接下来主要是针对上一篇 【中间件】Prometheus基于AOP实现埋点采集上报 上报的Histogram数据,来配置一套相对完整的业务监控大盘

I.大盘配置

1. 基本盘选择

直接到官网查找模板大盘,这里选择SpringBoot搭建的服务器项目,可以输入spring关键字进行检索

官网:https://grafana.com/grafana/dashboards?dataSource=prometheus&search=spring

比如我们这里选择 6756 作为基础模板;然后在Grafana上导入

导入之后,对于变量的依赖顺序根据实际情况调整一下,比如我希望第一个变量是application,在选择应用之后,再选择对应的实例ip;

修改步骤如下:

  • 依次选择:dashboard settings -> variables
  • 将application变量前置到instance前
  • application:
    • 修改query为: label_values(jvm_classes_loaded_classes, application)
  • instance:
    • 修改query为: label_values(jvm_classes_loaded_classes{application="$application"}, instance)
  • 新增service变量
    • query = label_values(micro_service_histogram_seconds_count{application="$application", instance="$instance"}, service)

注意

  • 一个变量的取值依赖另一个变量,请注意将被依赖的变量顺序放在前面
  • 一个变量的取值依赖另一个变量,写法是 metric{tag="$valName"}, 这个变量名前缀是$,且使用双引号包裹
  • metric的选择,可以通过直接查看目标服务器的metric接口查看,比如service变量选择的metric就是自定义上报的micro_service_histogram_seconds_count,而application与instance则选取的是Prometheus-Spring组件上报的spring应用基础信息中的metric

2. 业务盘配置

常见的业务指标,如QPS + RT + TPS + SLA等,接下来看一下如何进行配置

2.1 qps 每秒请求数

主要是借助内置函数rate来计算qps,通过一个计算时间窗口的平均增长速率,来展示接口的qps

(rate(micro_service_histogram_seconds_count{application="$application", instance="$instance", service="$service"}[1m]))

使用rate来计算qps时,会存在一个长尾问题,因为它实际上是根据1min内的所有样本数据,来计算平均增长率,因此当一个时间窗口内,存在瞬时的大数据场景,将不能很好的反应出来

因此更关注瞬时场景时,可以考虑使用irate来代替,它是通过一个时间范围内的区间向量数据中最后两个来计算增长速率的

2.2 rt 接口响应平均耗时

对于耗时的统计,也是比较重要的一个指标,用于判断我们系统的响应情况以及性能表现

核心配置: rate(sum / count)

(rate(micro_service_histogram_seconds_sum{application="$application", instance="$instance", service="$service"}[1m]))/(rate(micro_service_histogram_seconds_count{application="$application", instance="$instance", service="$service"}[1m]))

注意Y轴单位选择 seconds

2.3 接口耗时分布统计

基于Histogram样本数据,配合Grafana的热点图来配置耗时统计分布

sum(rate(micro_service_histogram_seconds_bucket{application="$application", instance="$instance", service="$service"}[1m])) by (le)

几个关键的配置

  • 配置参数中的Format 选择 Heatmap
  • 面板的Visualization中,选择 Heatmap
  • 面板中的Display,mode选择Opacity

根据颜色的深浅,来判断哪个bucket的请求量较多

2.4 SLA可用率

根据成功响应的计数 / 总计数来表征接口请求成功率

sum(micro_service_histogram_seconds_count{application="$application", instance="$instance", service="$service", err="false"}) / sum(micro_service_histogram_seconds_count{application="$application", instance="$instance", service="$service"}) * 100

3. 应用维度统计支持

前面的几个配置,统计面板都是基于某个应用,某个实例中的某个方法的维度进行展示,但实际情况是我们也很关注整体应用维度的表现情况

因此我们需要在变量选择中,支持全部

  • 开启变量的include all, 并设置custorm all value = .*

  • 第二步就是修改PromQL,将完全匹配调整为正则匹配 (将=改成=~)

4.小结

本文主要是通过grafana的大盘配置来展示如何使用Prometheus采集的数据,为了更好的使用采集数据,PromQL又是一个无法避免的知识点,下篇博文将带来PromQL的科普

II. 不能错过的源码和相关知识点

0. 项目

系列博文:

1. 微信公众号: 一灰灰Blog

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

一灰灰blog

3 - Zookeeper

SpringBoot整合Zookeeper,并通过zk实现分布式锁来实战演示zk在应用中的想象空间

3.1 - 1.基础使用介绍

ZooKeeper是一个分布式的,开放源码的分布式应用程序协调服务,广泛应用于分布式系统中,比如有用它做配置中心,注册中心,也有使用它来实现分布式锁的,作为高并发技术栈中不可或缺的一个基础组件,接下来我们将看一下,zk应该怎么玩,可以怎么玩

本文作为第一篇,将主要介绍基于zk-client的基本使用姿势,以次来了解下zk的基本概念

I. 准备

1. zk环境安装

用于学习试点目的的体验zk功能,安装比较简单,可以参考博文: 210310-ZooKeeper安装及初体验

wget https://mirrors.bfsu.edu.cn/apache/zookeeper/zookeeper-3.6.2/apache-zookeeper-3.6.2-bin.tar.gz
tar -zxvf apache-zookeeper-3.6.2-bin.tar.gz
cd apache-zookeeper-3.6.2-bin

# 前台启动
bin/zkServer.sh start-foreground

2. 项目环境

本文演示的是直接使用apache的zookeeper包来操作zk,与是否是SpringBoot环境无关

核心依赖

<!-- https://mvnrepository.com/artifact/org.apache.zookeeper/zookeeper -->
<dependency>
    <groupId>org.apache.zookeeper</groupId>
    <artifactId>zookeeper</artifactId>
    <version>3.7.0</version>
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-log4j12</artifactId>
        </exclusion>
    </exclusions>
</dependency>

版本说明:

  • zk: 3.6.2
  • SpringBoot: 2.2.1.RELEASE

II. ZK使用姿势

1. zk基本知识点

首先介绍下zk的几个主要的知识点,如zk的数据模型,四种常说的节点

1.1 数据模型

zk的数据模型和我们常见的目录树很像,从/开始,每一个层级就是一个节点

每个节点,包含数据 + 子节点

注意:EPHEMERAL节点,不能有子节点(可以理解为这个目录下不能再挂目录)

zk中常说的监听器,就是基于节点的,一般来讲监听节点的创建、删除、数据变更

1.2 节点

  • 持久节点 persistent node
  • 持久顺序节点 persistent sequental
  • 临时节点 ephemeral node
  • 临时顺序节点 ephemeral sequental

注意:

  • 节点类型一经指定,不允许修改
  • 临时节点,当会话结束,会自动删除,且不能有子节点

2. 节点创建

接下来我们看一下zk的使用姿势,首先是创建节点,当然创建前提是得先拿到zkClient

初始化连接

private ZooKeeper zooKeeper;

@PostConstruct
public void initZk() throws IOException {
    // 500s 的会话超时时间
    zooKeeper = new ZooKeeper("127.0.0.1:2181", 500_000, this);
}

节点创建方法,下面分别给出两种不同的case

@Service
public class NodeExample implements Watcher {
    /**
     * 创建节点
     *
     * @param path
     */
    private void nodeCreate(String path) {
        // 第三个参数ACL 表示访问控制权限
        // 第四个参数,控制创建的是持久节点,持久顺序节点,还是临时节点;临时顺序节点
        // 返回 the actual path of the created node
        // 单节点存在时,抛异常 KeeperException.NodeExists
        try {
            String node = zooKeeper.create(path + "/yes", "保存的数据".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            System.out.println("create node: " + node);
        } catch (KeeperException.NodeExistsException e) {
            // 节点存在
            System.out.println("节点已存在: " + e.getMessage());
        } catch (Exception e) {
            e.printStackTrace();
        }

        // 带生命周期的节点
        try {
            Stat stat = new Stat();
            // 当这个节点上没有child,且1s内没有变动,则删除节点
            // 实测抛了异常,未知原因
            String node = zooKeeper.create(path + "/ttl", ("now: " + LocalDateTime.now()).getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_WITH_TTL, stat, 1000);
            System.out.println("ttl nod:" + node + " | " + stat);
            // 创建已给监听器来验证
            zooKeeper.exists(path + "/ttl", (e) -> {
                System.out.println("ttl 节点变更: " + e);
            });
        } catch (KeeperException.NodeExistsException e) {
            System.out.println("节点已存在: " + e.getMessage());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

节点创建,核心在于 zooKeeper.create(path + "/yes", "保存的数据".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);

  • 当节点已存在时,再创建会抛异常 KeeperException.NodeExistsException
  • 最后一个参数,来决定我们创建的节点类型
  • todo: 上面实例中在指定ttl时,没有成功,暂未找到原因,待解决

3. 节点存在判断

判断节点是否存在,比较常见了(比如我们在创建之前,可能会先判断一下是否存在)

/**
 * 判断节点是否存在
 */
private void checkPathExist(String path) {
    try {
        // 节点存在,则返回stat对象; 不存在时,返回null
        // watch: true 表示给这个节点添加监听器,当节点出现创建/删除 或者 新增数据时,触发watcher回调
        Stat stat = zooKeeper.exists(path + "/no", false);
        System.out.println("NoStat: " + stat);
    } catch (Exception e) {
        e.printStackTrace();
    }

    try {
        // 判断节点是否存在,并监听 节点的创建 + 删除 + 数据变更
        // 注意这个事件监听,只会触发一次,即单这个节点数据变更多次,只有第一次能拿到,之后的变动,需要重新再注册监听
        Stat stat = zooKeeper.exists(path + "/yes", this);
        System.out.println("YesStat: " + stat);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

注意

核心用法: zooKeeper.exists(path + "/yes", this);

  • 当节点存在时,返回Stat对象,包含一些基本信息;如果不存在,则返回null
  • 第二个参数,传入的是事件回调对象,我们的测试类NodeExmaple 实现了接口 Watcher, 所以直接传的是this
  • 注册事件监听时,需要注意这个回调只会执行一次,即触发之后就没了;后面再次修改、删除、创建节点都不会再被接收到

4. 子节点获取

获取某个节点的所有子节点,这里返回的是当前节点的一级子节点

/**
 * 获取节点的所有子节点, 只能获取一级节点
 *
 * @param path
 */
private void nodeChildren(String path) {
    try {
        // 如果获取成功,会监听 当前节点的删除,子节点的创建和删除,触发回调事件, 这个回调也只会触发一次
        List<String> children = zooKeeper.getChildren(path, this, new Stat());
        System.out.println("path:" + path + " 's children:" + children);
    } catch (KeeperException e) {
        System.out.println(e.getMessage());
    } catch (Exception e) {
        e.printStackTrace();
    }
}

5. 数据获取与修改

节点上是可以存储数据的,在创建的时候,可以加上数据;后期可以读取,也可以修改

/**
 * 设置数据,获取数据
 *
 * @param path
 */
public void dataChange(String path) {
    try {
        Stat stat = new Stat();
        byte[] data = zooKeeper.getData(path, false, stat);
        System.out.println("path: " + path + " data: " + new String(data) + " : " + stat);

        // 根据版本精确匹配; version = -1 就不需要进行版本匹配了
        Stat newStat = zooKeeper.setData(path, ("new data" + LocalDateTime.now()).getBytes(), stat.getVersion());
        System.out.println("newStat: " + stat.getVersion() + "/" + newStat.getVersion() + " data: " + new String(zooKeeper.getData(path, false, stat)));
    } catch (Exception e) {
        e.printStackTrace();
    }
}

在设置数据时,可以指定版本,当version > 0时,表示根据版本精确匹配;如果为-1时,则只要节点路径对上就成

6. 事件监听

监听主要是针对节点而言,前面在判断节点是否存在、修改数据时都可以设置监听器,但是他们是一次性的,如果我们希望长久有效,则可以使用下面的addWatch

public void watchEvent(String path) {
    try {
        // 注意这个节点存在
        // 添加监听, 与 exist判断节点是否存在时添加的监听器 不同的在于,触发之后,依然有效还会被触发, 只有手动调用remove才会取消
        // 感知: 节点创建,删除,数据变更 ; 创建子节点,删除子节点
        // 无法感知: 子节点的子节点创建/删除, 子节点的数据变更
        zooKeeper.addWatch(path + "/yes", new Watcher() {
            @Override
            public void process(WatchedEvent event) {
                System.out.println("事件触发 on " + path + " event:" + event);
            }
        }, AddWatchMode.PERSISTENT);
    } catch (Exception e) {
        e.printStackTrace();
    }

    try {
        // 注意这个节点不存在
        // 添加监听, 与 exist 不同的在于,触发之后,依然有效还会被触发, 只有手动调用remove才会取消
        // 与前面的区别在于,它的子节点的变动也会被监听到
        zooKeeper.addWatch(path + "/no", new Watcher() {
            @Override
            public void process(WatchedEvent event) {
                System.out.println("事件触发 on " + path + " event:" + event);
            }
        }, AddWatchMode.PERSISTENT_RECURSIVE);
    } catch (Exception e) {
        e.printStackTrace();
    }

    // 移除所有的监听
    //zooKeeper.removeAllWatches(path, WatcherType.Any, true);
}

上面给出了两种case,

  • AddWatchMode.PERSISTENT: 表示只关心当前节点的删除、数据变更,创建,一级子节点的创建、删除;无法感知子节点的子节点创建、删除,无法感知子节点的数据变更
  • AddWatchMode.PERSISTENT_RECURSIVE: 相当于递归监听,改节点及其子节点的所有变更都监听

7. 节点删除

最后再介绍一个基本功能,节点删除,只有子节点都不存在时,才能删除当前节点(和linux的rmdir类似)

/**
 * 删除节点
 */
public void deleteNode(String path) {
    try {
        // 根据版本限定删除, -1 表示不需要管版本,path匹配就可以执行;否则需要版本匹配,不然就会抛异常
        zooKeeper.delete(path, -1);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

8. 小结

本文主要介绍的是java侧对zookeeper的基本操作姿势,可以算是zk的入门,了解下节点的增删改,事件监听;

当然一般更加推荐的是使用Curator来操作zk,相比较于apache的jar包,使用姿势更加顺滑,后面也会做对比介绍

II. 其他

0. 项目

3.2 - 2.从0到1实现一个分布式锁

分布式锁,在实际的业务使用场景中算是比较常用的了,而分布式锁的实现,常见的除了redis之外,就是zk的实现了,前面一篇博文介绍了zk的基本概念与使用姿势,那么如果让我们来记住zk的特性来设计一个分布式锁,可以怎么做呢?

I. 方案设计

1. 创建节点方式实现

zk有四种节点,一个最容易想到的策略就是创建节点,谁创建成功了,就表示谁持有了这个锁

这个思路与redis的setnx有点相似,因为zk的节点创建,也只会有一个会话会创建成功,其他的则会抛已存在的异常

借助临时节点,会话丢掉之后节点删除,这样可以避免持有锁的实例异常而没有主动释放导致所有实例都无法持有锁的问题

如果采用这种方案,如果我想实现阻塞获取锁的逻辑,那么其中一个方案就需要写一个while(true)来不断重试

while(true) {
    if (tryLock(xxx)) return true;
    else Thread.sleep(1000);
}

另外一个策略则是借助事件监听,当节点存在时,注册一个节点删除的触发器,这样就不需要我自己重试判断了;充分借助zk的特性来实现异步回调

public void lock() {
  if (tryLock(path,  new Watcher() {
        @Override
        public void process(WatchedEvent event) {
            synchronized (path){
                path.notify();
            }
        }
    })) {
      return true;
  }

  synchronized (path) {
      path.wait();
  }
}

那么上面这个实现有什么问题呢?

每次节点的变更,那么所有的都会监听到变动,好处是非公平锁的支持;缺点就是剩下这些唤醒的实例中也只会有一个抢占到锁,无意义的唤醒浪费性能

2. 临时顺序节点方式

接下来这种方案更加常见,晚上大部分的教程也是这种case,主要思路就是创建临时顺序节点

只有序号最小的节点,才表示抢占锁成功;如果不是最小的节点,那么就监听它前面一个节点的删除事件,前面节点删除了,一种可能是他放弃抢锁,一种是他释放自己持有的锁,不论哪种情况,对我而言,我都需要捞一下所有的节点,要么拿锁成功;要么换一个前置节点

II.分布式锁实现

接下来我们来一步步看下,基于临时顺序节点,可以怎么实现分布式锁

对于zk,我们依然采用apache的提供的包 zookeeper来操作;后续提供Curator的分布式锁实例

1. 依赖

核心依赖

<!-- https://mvnrepository.com/artifact/org.apache.zookeeper/zookeeper -->
<dependency>
    <groupId>org.apache.zookeeper</groupId>
    <artifactId>zookeeper</artifactId>
    <version>3.7.0</version>
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-log4j12</artifactId>
        </exclusion>
    </exclusions>
</dependency>

版本说明:

  • zk版本: 3.6.2
  • SpringBoot: 2.2.1.RELEASE

2. 简单的分布式锁

第一步,都是实例创建

public class ZkLock implements Watcher {

    private ZooKeeper zooKeeper;
    // 创建一个持久的节点,作为分布式锁的根目录
    private String root;

    public ZkLock(String root) throws IOException {
        try {
            this.root = root;
            zooKeeper = new ZooKeeper("127.0.0.1:2181", 500_000, this);
            Stat stat = zooKeeper.exists(root, false);
            if (stat == null) {
                // 不存在则创建
                createNode(root, true);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    
    // 简单的封装节点创建,这里只考虑持久 + 临时顺序
    private String createNode(String path, boolean persistent) throws Exception {
        return zooKeeper.create(path, "0".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, persistent ? CreateMode.PERSISTENT : CreateMode.EPHEMERAL_SEQUENTIAL);
    }
}

在我们的这个设计中,我们需要持有当前节点和监听前一个节点的变更,所以我们在ZkLock实例中,添加两个成员

/**
 * 当前节点
 */
private String current;

/**
 * 前一个节点
 */
private String pre;

接下来就是尝试获取锁的逻辑

  • current不存在,在表示没有创建过,就创建一个临时顺序节点,并赋值current
  • current存在,则表示之前已经创建过了,目前处于等待锁释放过程
  • 接下来根据当前节点顺序是否最小,来表明是否持有锁成功
  • 当顺序不是最小时,找前面那个节点,并赋值 pre;
  • 监听pre的变化
/**
 * 尝试获取锁,创建顺序临时节点,若数据最小,则表示抢占锁成功;否则失败
 *
 * @return
 */
public boolean tryLock() {
    try {
        String path = root + "/";
        if (current == null) {
            // 创建临时顺序节点
            current = createNode(path, false);
        }
        List<String> list = zooKeeper.getChildren(root, false);
        Collections.sort(list);

        if (current.equalsIgnoreCase(path + list.get(0))) {
            // 获取锁成功
            return true;
        } else {
            // 获取锁失败,找到前一个节点
            int index = Collections.binarySearch(list, current.substring(path.length()));
            // 查询当前节点前面的那个
            pre = path + list.get(index - 1);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return false;
}

请注意上面的实现,这里并没有去监听前一个节点的变更,在设计tryLock,因为是立马返回成功or失败,所以使用这个接口的,不需要注册监听

我们的监听逻辑,放在 lock() 同步阻塞里面

  • 尝试抢占锁,成功则直接返回
  • 拿锁失败,则监听前一个节点的删除事件
public boolean lock() {
    if (tryLock()) {
        return true;
    }

    try {
        // 监听前一个节点的删除事件
        Stat state = zooKeeper.exists(pre, true);
        if (state != null) {
            synchronized (pre) {
                // 阻塞等待前面的节点释放
                pre.wait();
                // 这里不直接返回true,因为前面的一个节点删除,可能并不是因为它持有锁并释放锁,如果是因为这个会话中断导致临时节点删除,这个时候需要做的是换一下监听的 preNode
                return lock();
            }
        } else {
          // 不存在,则再次尝试拿锁
          return lock();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return false;
}

注意:

  • 当节点不存在时,或者事件触发回调之后,重新调用lock(),表明我胡汉三又来竞争锁了?

为啥不是直接返回 true? 而是需要重新竞争呢?

  • 因为前面节点的删除,有可能是因为前面节点的会话中断导致的;但是锁还在另外的实例手中,这个时候我应该做的是重新排队

最后别忘了释放锁

public void unlock() {
    try {
        zooKeeper.delete(current, -1);
        current = null;
        zooKeeper.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

到此,我们的分布式锁就完成了,接下来我们复盘下实现过程

  • 所有知识点来自前一篇的zk基础使用(创建节点,删除节点,获取所有自己点,监听事件)
  • 抢锁过程 =》 创建序号最小的节点
  • 若节点不是最小的,那么就监听前面的节点删除事件

这个实现,支持了锁的重入(why? 因为锁未释放时,我们保存了current,当前节点存在时则直接判断是不是最小的;而不是重新创建)

3. 测试

最后写一个测试case,来看下

@SpringBootApplication
public class Application {

    private void tryLock(long time) {
        ZkLock zkLock = null;
        try {
            zkLock = new ZkLock("/lock");
            System.out.println("尝试获取锁: " + Thread.currentThread() + " at: " + LocalDateTime.now());
            boolean ans = zkLock.lock();
            System.out.println("执行业务逻辑:" + Thread.currentThread() + " at:" + LocalDateTime.now());
            Thread.sleep(time);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (zkLock != null) {
                zkLock.unlock();
            }
        }
    }

    public Application() throws IOException, InterruptedException {
        new Thread(() -> tryLock(10_000)).start();

        Thread.sleep(1000);
        // 获取锁到执行锁会有10s的间隔,因为上面的线程抢占到锁,并持有了10s
        new Thread(() -> tryLock(1_000)).start();
        System.out.println("---------over------------");

        Scanner scanner = new Scanner(System.in);
        String ans = scanner.next();
        System.out.println("---> over --->" + ans);
    }

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

}

输出结果如下

II. 其他

0. 项目

4 - Docker

SpringBoot配置docker,实现容器化管理部署

4.1 - 1.整合docker镜像打包

SpringBoot项目整合docker,打包镜像工程演示

I. 整合步骤

1. 基本环境

本项目借助SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA + MAC进行开发

首先确保本机有安装docker,对于docker基本知识点,可以参考

2. pom配置

创建一个基本的SpringBoot项目之后,关键是设置pom.xml文件,我们主要借助docker-maven-plugin来打镜像包

一个可用的配置如下

<artifactId>400-docker-demo</artifactId>

<properties>
    <docker.prefix>springboot</docker.prefix>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <fork>true</fork>
                <mainClass>com.git.hui.boot.docker.Application</mainClass>
            </configuration>
        </plugin>

        <!--加入maven插件 docker-maven-plugin -->
        <plugin>
            <groupId>com.spotify</groupId>
            <artifactId>docker-maven-plugin</artifactId>
            <version>0.4.13</version>
            <configuration>
                <!--镜像仓库源-->
                <imageName>${docker.prefix}/${project.artifactId}</imageName>
                <!--docker配置文件的路径-->
                <dockerDirectory>./</dockerDirectory>
                <resources>
                    <resource>
                        <targetPath>/</targetPath>
                        <directory>${project.build.directory}</directory>
                        <include>${project.build.finalName}.jar</include>
                    </resource>
                </resources>
            </configuration>
        </plugin>
    </plugins>
</build>

3. Dockerfile 文件

接下需要配置我们自己的dockerfile文件,在项目根目录下,新建文件名Dockerfile,如果路径有修改,需要调整上面pom配置中的dockerDirectory参数

FROM openjdk:8-jdk-alpine as builder
MAINTAINER yihui

# 创建工作目录
RUN mkdir -p /home/yihui/workspace/app
# 将jar拷贝过去
COPY /target/400-docker-demo-0.0.1-SNAPSHOT.jar /home/yihui/workspace/app/app.jar
# 将我们预期的文件拷贝过去
COPY /readme.md /home/yihui/workspace/app/readme.md
# 指定工作目录
WORKDIR /home/yihui/workspace/app
# 运行jar
ENTRYPOINT ["java", "-jar", "app.jar"]

关于dockerfile语法,可以参考

我们上面的case,就是拉一个jdk8的运行环境,将打的jar包重命名为app.jar到指定目录,同时使用命令java -jar app.jar来启动应用

4. 测试demo

提供一个最基础的demo实例

@SpringBootApplication
@RestController
public class Application {

    @GetMapping(path = {"", "/"})
    public String hello() {
        return "hello " + UUID.randomUUID();
    }

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

打包命令 mvn clean package docker:build -DskipTests=true

注意

  • 打包的前提是docker已经启动了

打包成功之后,可以看到会多一个docker镜像

docker images

运行镜像并测试

# 运行
docker run -i -d --name ddemo -p 8080:8080 -t springboot/400-docker-demo
# 测试
curl 'http://127.0.0.1:8080'

II. 其他

0. 项目