乐趣区

关于后端:AOP实现系统告警

工作群里的音讯怕过于宁静,又怕过于频繁

一、业务背景

在开发的过程中会遇到各种各样的开发问题,服务器宕机、网络抖动、代码自身的 bug 等等。针对代码的 bug,咱们能够提前预支,通过发送告警信息来警示咱们去干涉,尽早解决。

二、告警的形式

1、钉钉告警

通过在企业钉钉群,增加群机器人的形式,通过机器人向群内发送报警信息。至于钉钉机器人怎么创立,发送音讯的 api 等等,请参考官网文档

2、企业微信告警

同样的套路,企业微信也是,在企业微信群中,增加群机器人。通过机器人发送告警信息。具体请看官网文档

3、邮件告警

与上述不同的是,邮件是发送给集体的,当然也能够是批量发送,只实现了发送文本格式的形式,至于 markdown 格局,有待考查。邮件发送绝对比较简单,这里就不开展赘述。

三、源码解析

1、Alarm 自定义注解

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface Alarm {

    /**
     * 报警题目
     *
     * @return String
     */
    String title() default "";

    /**
     * 发送报警格局:目前反对 text,markdown
     * @return
     */
    MessageTye messageType() default MessageTye.TEXT;

    /**
     * 告警模板 id
     * @return
     */
    String templateId() default "";

    /**
     * 胜利是否告诉:true- 告诉,false- 不告诉
     * @return
     */
    boolean successNotice() default false;}

1.1、注解应用

@Alarm标记在办法上应用,被标记的办法产生异样,会依据配置,读取配置信息,发送异样堆栈信息。应用办法如下所示:

@Alarm(title = "某某业务告警", messageType = MessageTye.MARKDOWN, templateId = "errorTemp")

1.2、注解字段解析

  1. title

告警音讯题目:能够定义为业务信息,如导师身份计算

  1. messageType

告警音讯展现类型:目前反对 text 文本类型,markdown 类型

  1. templateId

音讯模板 id:与配置文件中配置的模板 id 统一

  1. successNotice

失常状况是否也须要发送告警信息,默认值是 fasle,示意不须要发送。当然,有些业务场景失常状况也须要发送,比方:领取出单告诉等。

2、配置文件剖析

2.1、钉钉配置文件

spring:
  alarm:
    dingtalk:
        # 开启钉钉发送告警
      enabled: true
        # 钉钉群机器人惟一的 token
      token: xxxxxx
        # 平安设置:加签的密钥
      secret: xxxxxxx

2.2、企业微信配置文件

spring:
  alarm:
    wechat:
        # 开启企业微信告警
      enabled: true
        # 企业微信群机器人惟一 key
      key: xxxxxdsf
        # 被 @人的手机号
      to-user: 1314243

2.3、邮件配置文件

spring:
  alarm:    
    mail:
      enabled: true
      smtpHost: xxx@qq.com
      smtpPort: 22
      to: xxx@qq.com
      from: 132@qq.com
      username: wsrf
      password: xxx

2.4、自定义模板配置

spring:
  alarm:
    template:
      # 开启通过模板配置
      enabled: true
      # 配置模板起源为文件
      source: FILE
      # 配置模板数据
      templates:
        errorTemp:
          templateId: errorTemp
          templateName: 服务异样模板
          templateContent: 这里是配置模板的内容
  • spring:alarm:template:enabled,Boolean 类型,示意开启告警音讯应用模板发送。
  • spring:alarm:template:source,模板起源,枚举类:JDBC(数据库)、FILE(配置文件)、MEMORY(内存),目前只反对 FILE,其余两种可自行扩大。
  • spring:alarm:template:templates,配置模板内容,是一个 map,errorTemp是模板 id,须要应用哪种模板,就在 @Alarm 中的 templateId 设置为对应配置文件中的 templateId。

    3、外围 AOP 剖析

    3.1、原理剖析

    3.2、自定义切面

    @Aspect
    @Slf4j
    @RequiredArgsConstructor
    public class AlarmAspect {
      private final AlarmTemplateProvider alarmTemplateProvider;
    
      private final static String ERROR_TEMPLATE = "\n\n<font color=\"#F37335\"> 异样信息:</font>\n" +
              "```java\n" +
              "#{[exception]}\n" +
              "```\n";
    
      private final static String TEXT_ERROR_TEMPLATE = "\n 异样信息:\n" +
              "#{[exception]}";
    
      private final static String MARKDOWN_TITLE_TEMPLATE = "#【#{[title]}】\n" +
              "\n 申请状态:<font color=\"#{[stateColor]}\">#{[state]}</font>\n\n";
    
      private final static String TEXT_TITLE_TEMPLATE = "【#{[title]}】\n" +
              "申请状态:#{[state]}\n";
    
      @Pointcut("@annotation(alarm)")
      public void alarmPointcut(Alarm alarm) { }
    
      @Around(value = "alarmPointcut(alarm)", argNames = "joinPoint,alarm")
      public Object around(ProceedingJoinPoint joinPoint, Alarm alarm) throws Throwable {Object result = joinPoint.proceed();
          if (alarm.successNotice()) {String templateId = alarm.templateId();
              String fileTemplateContent = "";
              if (Objects.nonNull(alarmTemplateProvider)) {AlarmTemplate alarmTemplate = alarmTemplateProvider.loadingAlarmTemplate(templateId);
                  fileTemplateContent = alarmTemplate.getTemplateContent();}
              String templateContent = "";
              MessageTye messageTye = alarm.messageType();
              if (messageTye.equals(MessageTye.TEXT)) {templateContent = TEXT_TITLE_TEMPLATE.concat(fileTemplateContent);
              } else if (messageTye.equals(MessageTye.MARKDOWN)) {templateContent = MARKDOWN_TITLE_TEMPLATE.concat(fileTemplateContent);
              }
              Map<String, Object> alarmParamMap = new HashMap<>();
              alarmParamMap.put("title", alarm.title());
              alarmParamMap.put("stateColor", "#45B649");
              alarmParamMap.put("state", "胜利");
              sendAlarm(alarm, templateContent, alarmParamMap);
          }
          return result;
      }
    
    
      @AfterThrowing(pointcut = "alarmPointcut(alarm)", argNames = "joinPoint,alarm,e", throwing = "e")
      public void doAfterThrow(JoinPoint joinPoint, Alarm alarm, Exception e) {log.info("申请接口产生异样 : [{}]", e.getMessage());
          String templateId = alarm.templateId();
          // 加载模板中配置的内容,若有
          String templateContent = "";
          String fileTemplateContent = "";
          if (Objects.nonNull(alarmTemplateProvider)) {AlarmTemplate alarmTemplate = alarmTemplateProvider.loadingAlarmTemplate(templateId);
              fileTemplateContent = alarmTemplate.getTemplateContent();}
          MessageTye messageTye = alarm.messageType();
          if (messageTye.equals(MessageTye.TEXT)) {templateContent = TEXT_TITLE_TEMPLATE.concat(fileTemplateContent).concat(TEXT_ERROR_TEMPLATE);
          } else if (messageTye.equals(MessageTye.MARKDOWN)) {templateContent = MARKDOWN_TITLE_TEMPLATE.concat(fileTemplateContent).concat(ERROR_TEMPLATE);
          }
          Map<String, Object> alarmParamMap = new HashMap<>();
          alarmParamMap.put("title", alarm.title());
          alarmParamMap.put("stateColor", "#FF4B2B");
          alarmParamMap.put("state", "失败");
          alarmParamMap.put("exception", ExceptionUtil.stacktraceToString(e));
          sendAlarm(alarm, templateContent, alarmParamMap);
      }
    
      private void sendAlarm(Alarm alarm, String templateContent, Map<String, Object> alarmParamMap) {ExpressionParser parser = new SpelExpressionParser();
          TemplateParserContext parserContext = new TemplateParserContext();
          String message = parser.parseExpression(templateContent, parserContext).getValue(alarmParamMap, String.class);
          MessageTye messageTye = alarm.messageType();
          NotifyMessage notifyMessage = new NotifyMessage();
          notifyMessage.setTitle(alarm.title());
          notifyMessage.setMessageTye(messageTye);
          notifyMessage.setMessage(message);
          AlarmFactoryExecute.execute(notifyMessage);
      }
    }

    4、模板提供器

    4.1、AlarmTemplateProvider

    定义一个形象接口AlarmTemplateProvider,用于被具体的子类实现

public interface AlarmTemplateProvider {


    /**
     * 加载告警模板
     *
     * @param templateId 模板 id
     * @return AlarmTemplate
     */
    AlarmTemplate loadingAlarmTemplate(String templateId);
}

4.2、BaseAlarmTemplateProvider

抽象类 BaseAlarmTemplateProvider 实现该形象接口

public abstract class BaseAlarmTemplateProvider implements AlarmTemplateProvider {

    @Override
    public AlarmTemplate loadingAlarmTemplate(String templateId) {if (StringUtils.isEmpty(templateId)) {throw new AlarmException(400, "告警模板配置 id 不能为空");
        }
        return getAlarmTemplate(templateId);
    }

    /**
     * 查问告警模板
     *
     * @param templateId 模板 id
     * @return AlarmTemplate
     */
    abstract AlarmTemplate getAlarmTemplate(String templateId);
}

4.3、YamlAlarmTemplateProvider

具体实现类YamlAlarmTemplateProvider,实现从配置文件中读取模板,该类在我的项目启动时,会被加载进 spring 的 bean 容器

@RequiredArgsConstructor
public class YamlAlarmTemplateProvider extends BaseAlarmTemplateProvider {

    private final TemplateConfig templateConfig;

    @Override
    AlarmTemplate getAlarmTemplate(String templateId) {Map<String, AlarmTemplate> configTemplates = templateConfig.getTemplates();
        AlarmTemplate alarmTemplate = configTemplates.get(templateId);
        if (ObjectUtils.isEmpty(alarmTemplate)) {throw new AlarmException(400, "未发现告警配置模板");
        }
        return alarmTemplate;
    }
}

4.4、MemoryAlarmTemplateProvider 和 JdbcAlarmTemplateProvider

抽象类 BaseAlarmTemplateProvider 还有其余两个子类,别离是 MemoryAlarmTemplateProviderJdbcAlarmTemplateProvider。然而这两个子类临时还未实现逻辑,后续能够自行扩大。

@RequiredArgsConstructor
public class MemoryAlarmTemplateProvider extends BaseAlarmTemplateProvider {

    private final Function<String, AlarmTemplate> function;
    @Override
    AlarmTemplate getAlarmTemplate(String templateId) {AlarmTemplate alarmTemplate = function.apply(templateId);
        if (ObjectUtils.isEmpty(alarmTemplate)) {throw new AlarmException(400, "未发现告警配置模板");
        }
        return alarmTemplate;
    }
}
@RequiredArgsConstructor
public class JdbcAlarmTemplateProvider extends BaseAlarmTemplateProvider {

    private final Function<String, AlarmTemplate> function;

    @Override
    AlarmTemplate getAlarmTemplate(String templateId) {AlarmTemplate alarmTemplate = function.apply(templateId);
        if (ObjectUtils.isEmpty(alarmTemplate)) {throw new AlarmException(400, "未发现告警配置模板");
        }
        return alarmTemplate;
    }
}

两个类中都有 Function<String, AlarmTemplate> 接口,为函数式接口,能够供内部自行去实现逻辑。

5、告警发送

5.1、AlarmFactoryExecute

该类外部保留了一个容器,次要用于缓存真正的发送类

public class AlarmFactoryExecute {private static List<AlarmWarnService> serviceList = new ArrayList<>();

    public AlarmFactoryExecute(List<AlarmWarnService> alarmLogWarnServices) {serviceList = alarmLogWarnServices;}

    public static void addAlarmLogWarnService(AlarmWarnService alarmLogWarnService) {serviceList.add(alarmLogWarnService);
    }

    public static List<AlarmWarnService> getServiceList() {return serviceList;}

    public static void execute(NotifyMessage notifyMessage) {for (AlarmWarnService alarmWarnService : getServiceList()) {alarmWarnService.send(notifyMessage);
        }
    }
}

5.2、AlarmWarnService

形象接口,只提供一个发送的办法

public interface AlarmWarnService {

    /**
     * 发送信息
     *
     * @param notifyMessage message
     */
    void send(NotifyMessage notifyMessage);

}

5.3、BaseWarnService

与形象的模板提供器 AlarmTemplateProvider 一样的套路,该接口有一个形象的实现类BaseWarnService, 该类对外裸露 send 办法,用于发送音讯,外部用 doSendMarkdown,doSendText 办法实现具体的发送逻辑,当然具体发送逻辑还是得由其子类去实现。

@Slf4j
public abstract class BaseWarnService implements AlarmWarnService {

    @Override
    public void send(NotifyMessage notifyMessage) {if (notifyMessage.getMessageTye().equals(MessageTye.TEXT)) {CompletableFuture.runAsync(() -> {
                try {doSendText(notifyMessage.getMessage());
                } catch (Exception e) {log.error("send text warn message error", e);
                }
            });
        } else if (notifyMessage.getMessageTye().equals(MessageTye.MARKDOWN)) {CompletableFuture.runAsync(() -> {
                try {doSendMarkdown(notifyMessage.getTitle(), notifyMessage.getMessage());
                } catch (Exception e) {log.error("send markdown warn message error", e);
                }
            });
        }
    }

    /**
     * 发送 Markdown 音讯
     *
     * @param title   Markdown 题目
     * @param message Markdown 音讯
     * @throws Exception 异样
     */
    protected abstract void doSendMarkdown(String title, String message) throws Exception;

    /**
     * 发送文本音讯
     *
     * @param message 文本音讯
     * @throws Exception 异样
     */
    protected abstract void doSendText(String message) throws Exception;
}

5.4、DingTalkWarnService

次要实现了钉钉发送告警信息的逻辑

@Slf4j
public class DingTalkWarnService extends BaseWarnService {

    private static final String ROBOT_SEND_URL = "https://oapi.dingtalk.com/robot/send?access_token=";
    private final String token;

    private final String secret;

    public DingTalkWarnService(String token, String secret) {
        this.token = token;
        this.secret = secret;
    }

    public void sendRobotMessage(DingTalkSendRequest dingTalkSendRequest) throws Exception {String json = JSONUtil.toJsonStr(dingTalkSendRequest);
        String sign = getSign();
        String body = HttpRequest.post(sign).contentType(ContentType.JSON.getValue()).body(json).execute().body();
        log.info("钉钉机器人告诉后果:{}", body);
    }

    /**
     * 获取签名
     *
     * @return 返回签名
     */
    private String getSign() throws Exception {long timestamp = System.currentTimeMillis();
        String stringToSign = timestamp + "\n" + secret;
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] signData = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8));
        return ROBOT_SEND_URL + token + "&timestamp=" + timestamp + "&sign=" + URLEncoder.encode(new String(Base64.getEncoder().encode(signData)), StandardCharsets.UTF_8.toString());
    }

    @Override
    protected void doSendText(String message) throws Exception {DingTalkSendRequest param = new DingTalkSendRequest();
        param.setMsgtype(DingTalkSendMsgTypeEnum.TEXT.getType());
        param.setText(new DingTalkSendRequest.Text(message));
        sendRobotMessage(param);
    }

    @Override
    protected void doSendMarkdown(String title, String message) throws Exception {DingTalkSendRequest param = new DingTalkSendRequest();
        param.setMsgtype(DingTalkSendMsgTypeEnum.MARKDOWN.getType());
        DingTalkSendRequest.Markdown markdown = new DingTalkSendRequest.Markdown(title, message);
        param.setMarkdown(markdown);
        sendRobotMessage(param);
    }
}

5.5、WorkWeXinWarnService

次要实现了发送企业微信告警信息的逻辑

@Slf4j
public class WorkWeXinWarnService extends BaseWarnService {
    private static final String SEND_MESSAGE_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=%s";
    private final String key;

    private final String toUser;

    public WorkWeXinWarnService(String key, String toUser) {
        this.key = key;
        this.toUser = toUser;
    }

    private String createPostData(WorkWeXinSendMsgTypeEnum messageTye, String contentValue) {WorkWeXinSendRequest wcd = new WorkWeXinSendRequest();
        wcd.setMsgtype(messageTye.getType());
        List<String> toUsers = Arrays.asList("@all");
        if (StringUtils.isNotEmpty(toUser)) {String[] split = toUser.split("\\|");
            toUsers = Arrays.asList(split);
        }
        if (messageTye.equals(WorkWeXinSendMsgTypeEnum.TEXT)) {WorkWeXinSendRequest.Text text = new WorkWeXinSendRequest.Text(contentValue, toUsers);
            wcd.setText(text);
        } else if (messageTye.equals(WorkWeXinSendMsgTypeEnum.MARKDOWN)) {WorkWeXinSendRequest.Markdown markdown = new WorkWeXinSendRequest.Markdown(contentValue, toUsers);
            wcd.setMarkdown(markdown);
        }
        return JSONUtil.toJsonStr(wcd);
    }

    @Override
    protected void doSendText(String message) {String data = createPostData(WorkWeXinSendMsgTypeEnum.TEXT, message);
        String url = String.format(SEND_MESSAGE_URL, key);
        String resp = HttpRequest.post(url).body(data).execute().body();
        log.info("send work weixin message call [{}], param:{}, resp:{}", url, data, resp);
    }

    @Override
    protected void doSendMarkdown(String title, String message) {String data = createPostData(WorkWeXinSendMsgTypeEnum.MARKDOWN, message);
        String url = String.format(SEND_MESSAGE_URL, key);
        String resp = HttpRequest.post(url).body(data).execute().body();
        log.info("send work weixin message call [{}], param:{}, resp:{}", url, data, resp);
    }
}

5.6、MailWarnService

次要实现邮件告警逻辑

@Slf4j
public class MailWarnService extends BaseWarnService {

    private final String smtpHost;

    private final String smtpPort;

    private final String to;

    private final String from;

    private final String username;

    private final String password;

    private Boolean ssl = true;

    private Boolean debug = false;

    public MailWarnService(String smtpHost, String smtpPort, String to, String from, String username, String password) {
        this.smtpHost = smtpHost;
        this.smtpPort = smtpPort;
        this.to = to;
        this.from = from;
        this.username = username;
        this.password = password;
    }

    public void setSsl(Boolean ssl) {this.ssl = ssl;}

    public void setDebug(Boolean debug) {this.debug = debug;}

    @Override
    protected void doSendText(String message) throws Exception {Properties props = new Properties();
        props.setProperty("mail.smtp.auth", "true");
        props.setProperty("mail.transport.protocol", "smtp");
        props.setProperty("mail.smtp.host", smtpHost);
        props.setProperty("mail.smtp.port", smtpPort);
        props.put("mail.smtp.ssl.enable", true);
        Session session = Session.getInstance(props);
        session.setDebug(false);
        MimeMessage msg = new MimeMessage(session);
        msg.setFrom(new InternetAddress(from));
        for (String toUser : to.split(",")) {msg.setRecipient(MimeMessage.RecipientType.TO, new InternetAddress(toUser));
        }
        Map<String, String> map = JSONUtil.toBean(message, Map.class);
        msg.setSubject(map.get("subject"), "UTF-8");
        msg.setContent(map.get("content"), "text/html;charset=UTF-8");
        msg.setSentDate(new Date());
        Transport transport = session.getTransport();
        transport.connect(username, password);
        transport.sendMessage(msg, msg.getAllRecipients());
        transport.close();}

    @Override
    protected void doSendMarkdown(String title, String message) throws Exception {log.warn("暂不反对发送 Markdown 邮件");
    }
}

6、AlarmAutoConfiguration 主动拆卸类

使用了 springboot 自定义的 starter,再 META-INF 包下的配置文件 spring.factories 下,配置上该类

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
    com.seven.buttemsg.autoconfigure.AlarmAutoConfiguration

主动拆卸类,用于装载自定义的 bean

@Slf4j
@Configuration
public class AlarmAutoConfiguration {

    // 邮件相干配置装载
    @Configuration
    @ConditionalOnProperty(prefix = MailConfig.PREFIX, name = "enabled", havingValue = "true")
    @EnableConfigurationProperties(MailConfig.class)
    static class MailWarnServiceMethod {

        @Bean
        @ConditionalOnMissingBean(MailWarnService.class)
        public MailWarnService mailWarnService(final MailConfig mailConfig) {MailWarnService mailWarnService = new MailWarnService(mailConfig.getSmtpHost(), mailConfig.getSmtpPort(), mailConfig.getTo(), mailConfig.getFrom(), mailConfig.getUsername(), mailConfig.getPassword());
            mailWarnService.setSsl(mailConfig.getSsl());
            mailWarnService.setDebug(mailConfig.getDebug());
            AlarmFactoryExecute.addAlarmLogWarnService(mailWarnService);
            return mailWarnService;
        }
    }

    // 企业微信相干配置装载
    @Configuration
    @ConditionalOnProperty(prefix = WorkWeXinConfig.PREFIX, name = "enabled", havingValue = "true")
    @EnableConfigurationProperties(WorkWeXinConfig.class)
    static class WorkWechatWarnServiceMethod {

        @Bean
        @ConditionalOnMissingBean(MailWarnService.class)
        public WorkWeXinWarnService workWechatWarnService(final WorkWeXinConfig workWeXinConfig) {return new WorkWeXinWarnService(workWeXinConfig.getKey(), workWeXinConfig.getToUser());
        }

        @Autowired
        void setDataChangedListener(WorkWeXinWarnService workWeXinWarnService) {AlarmFactoryExecute.addAlarmLogWarnService(workWeXinWarnService);
        }
    }

    // 钉钉相干配置装载
    @Configuration
    @ConditionalOnProperty(prefix = DingTalkConfig.PREFIX, name = "enabled", havingValue = "true")
    @EnableConfigurationProperties(DingTalkConfig.class)
    static class DingTalkWarnServiceMethod {

        @Bean
        @ConditionalOnMissingBean(DingTalkWarnService.class)
        public DingTalkWarnService dingTalkWarnService(final DingTalkConfig dingtalkConfig) {DingTalkWarnService dingTalkWarnService = new DingTalkWarnService(dingtalkConfig.getToken(), dingtalkConfig.getSecret());
            AlarmFactoryExecute.addAlarmLogWarnService(dingTalkWarnService);
            return dingTalkWarnService;
        }
    }

    // 音讯模板配置装载
    @Configuration
    @ConditionalOnProperty(prefix = TemplateConfig.PREFIX, name = "enabled", havingValue = "true")
    @EnableConfigurationProperties(TemplateConfig.class)
    static class TemplateConfigServiceMethod {

        @Bean
        @ConditionalOnMissingBean
        public AlarmTemplateProvider alarmTemplateProvider(TemplateConfig templateConfig) {if (TemplateSource.FILE == templateConfig.getSource()) {return new YamlAlarmTemplateProvider(templateConfig);
            } else if (TemplateSource.JDBC == templateConfig.getSource()) {
                // 数据库(如 mysql)读取文件,未实现,可自行扩大
                return new JdbcAlarmTemplateProvider(templateId -> null);
            } else if (TemplateSource.MEMORY == templateConfig.getSource()) {
                // 内存(如 redis,本地内存)读取文件,未实现,可自行扩大
                return new MemoryAlarmTemplateProvider(templateId -> null);
            }
            return new YamlAlarmTemplateProvider(templateConfig);
        }


    }
    @Bean
    public AlarmAspect alarmAspect(@Autowired(required = false) AlarmTemplateProvider alarmTemplateProvider) {return new AlarmAspect(alarmTemplateProvider);
    }
}

四、总结

次要借助 spring 的切面技术,以及 springboot 的主动拆卸原理,实现了发送告警逻辑。对业务代码无侵入,只须要在业务代码上标记注解,就可实现可插拔的性能,比拟轻量。

五、参考源码

编程文档:https://gitee.com/cicadasmile/butte-java-note

利用仓库:https://gitee.com/cicadasmile/butte-flyer-parent
退出移动版