AOP實現系統告警

2022-09-19 12:03:02

工作群裡的訊息怕過於安靜,又怕過於頻繁

一、業務背景

在開發的過程中會遇到各種各樣的開發問題,伺服器宕機、網路抖動、程式碼本身的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: [email protected]
      smtpPort: 22
      to: [email protected]
      from: [email protected]
      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