之前公司的错误日志都发送到邮箱中,但是邮箱存在响应延迟,造成问题解决不及时,结合我们正在使用的通信方式,发送到钉钉中效果会更好些。
一般上,在开发过程中,像log4j2
、logback
日志框架都提供了很多Appender
,基本上可以满足大部分的业务需求了。但在一些特殊需求可以自定义Appender。本文主讲利用自定义Appender拦截error级别日志
以及springboot的全局异常拦截,并且记录异常的请求参数,全部推送到钉钉群中,研发只需要根据钉钉群异常详情可以轻松定位问题内容。
利用观察者模式,实现多个监听者可以同时接受到相同的错误信息,例如需要将异常体现在邮箱和钉钉中,本文主要讲错误信息推送到钉钉中。
自定义Appender
import ch.qos.logback.classic.spi.LoggingEvent;
import ch.qos.logback.core.AppenderBase;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class SyncCallbackAppender extends AppenderBase<LoggingEvent> {
private static List<SyncCallbackAppender.OnAppendListener> onAppendListeners = new ArrayList();
public SyncCallbackAppender() {
}
protected void append(LoggingEvent eventObject) {
onAppendListeners.forEach((item) -> {
item.onAppend(eventObject);
});
}
public static void addAppendListenr(SyncCallbackAppender.OnAppendListener listener) {
if (listener != null && !onAppendListeners.contains(listener)) {
onAppendListeners.add(listener);
}
}
public interface OnAppendListener {
void onAppend(LoggingEvent var1);
}
}
利用springmvc注解@ControllerAdvice,实现请求参数的全局预处理,拦截请求参数当发生异常时同样将请求参数输出到钉钉中
/**
* @version 1.0
* @title 获取请求参数信息
* @description
* @changeRecord
*/
@ControllerAdvice
public class RequestInfoAdvice implements RequestBodyAdvice {
private ThreadLocal<String> jsonRequestBody = new ThreadLocal<>();
@Override
public boolean supports(MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {
return true;
}
@Override
public Object handleEmptyBody(Object object, HttpInputMessage httpInputMessage, MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {
return object;
}
/**
* 获取原始的请求参数(json格式的)
*
* @param httpInputMessage
* @param methodParameter
* @param type
* @param aClass
* @return
* @throws IOException
*/
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage httpInputMessage, MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) throws IOException {
String contentType = RequestData.getRequest().getContentType();
if (contentType != null && "application/json".equalsIgnoreCase(contentType)) {
jsonRequestBody.set(null);
if (httpInputMessage != null && httpInputMessage.getBody() != null) {
byte[] bodyBytes = IOUtils.toByteArray(httpInputMessage.getBody());
jsonRequestBody.set(new String(bodyBytes));
return new HttpInputMessage() {
@Override
public HttpHeaders getHeaders() {
return httpInputMessage.getHeaders();
}
@Override
public InputStream getBody() {
return new ByteArrayInputStream(bodyBytes);
}
};
}
}
return httpInputMessage;
}
@Override
public Object afterBodyRead(Object object, HttpInputMessage httpInputMessage, MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {
return object;
}
/**
* 获取请求信息
*
* @return
*/
public String getRequestInfo() {
StringBuffer stringBuffer = new StringBuffer("");
HttpServletRequest req = RequestData.getRequest();
if (req == null) {
return "";
}
//获取请求方式
stringBuffer.append("\n");
stringBuffer.append("请求方式: " + req.getMethod());
stringBuffer.append("\n");
//获取完整请求路径
stringBuffer.append("请求地址: " + req.getRequestURL());
stringBuffer.append("\n");
//获取请求参数
stringBuffer.append("请求参数: " + getRequestParam(req));
//获取ip来源
stringBuffer.append("\n");
stringBuffer.append("IP来源: " + IpUtil.getUserIPString(req));
stringBuffer.append("\n");
stringBuffer.append("\n");
Enumeration<String> headerNames = req.getHeaderNames();
//获取获取的消息头名称,获取对应的值,并输出
while (headerNames.hasMoreElements()) {
String nextElement = headerNames.nextElement();
stringBuffer.append(nextElement + ":" + req.getHeader(nextElement));
stringBuffer.append("\n");
}
stringBuffer.append("\n");
return stringBuffer.toString();
}
/**
* 获取请求参数
*
* @param request
* @return
*/
public String getRequestParam(HttpServletRequest request) {
StringBuilder stringBuilder = new StringBuilder();
try {
if ("get".equalsIgnoreCase(request.getMethod())) {
stringBuilder.append(request.getQueryString());
} else if ("post".equalsIgnoreCase(request.getMethod())) {
String contentType = RequestData.getRequest().getContentType();
if (contentType != null && "application/json".equalsIgnoreCase(contentType)) {
String jsonParm = jsonRequestBody.get();
if (StringUtil.isNotEmpty(jsonParm)) {
try {
//格式化输出
return JSON.toJSONString(JSONObject.parse(jsonRequestBody.get()),
SerializerFeature.PrettyFormat, SerializerFeature.WriteMapNullValue, SerializerFeature.WriteDateUseDateFormat);
} catch (Exception ex) {
return jsonParm;
}
}
} else if (request.getParameterNames() != null) {
Enumeration<String> enumeration = request.getParameterNames();
while (enumeration.hasMoreElements()) {
String name = enumeration.nextElement();
String[] values = request.getParameterValues(name);
for (int i = 0; i < values.length; i++) {
String value = values[i];
stringBuilder.append(name);
stringBuilder.append("=");
stringBuilder.append(value);
if (i < values.length - 1) {
stringBuilder.append("&");
}
}
if (enumeration.hasMoreElements()) {
stringBuilder.append("&");
}
}
return URLDecoder.decode(stringBuilder.toString(), "utf-8");
}
}
return stringBuilder.toString();
} catch (Exception e) {
e.printStackTrace();
}
return "";
}
}
根据拦截的error日志处理堆栈信息,发送到钉钉群中
/**
* @version 1.0
* @title 把异常信息发送到钉钉报警群
* @description
* @changeRecord
*/
@Service
public class ErrorToDingtalkService implements SyncCallbackAppender.OnAppendListener {
/**
* 获取相关请求参数
*/
@Resource
private RequestInfoAdvice requestInfoAdvice;
/**
* 钉钉通知类
*/
@Resource
private DingTalkAlarm dingTalkError;
/**
* 将异常信息缓存到redis中
*/
@Resource
private ICacheOperation cacheOperation;
public ErrorToDingtalkService() {
//注册接收日志需要被通知的通知者
SyncCallbackAppender.addAppendListenr(this);
}
@Override
public void onAppend(LoggingEvent eventObject) {
try {
handLoggingEvent(eventObject);
} catch (Throwable throwable) {
}
}
/**
* 钉钉报警详情
* 记录上次发送时间以及出现次数
*/
@Data
static class ReportDetail implements Serializable {
private String lastSendTime;//最近一次发送时间
private long count;//出错次数
}
/**
* 处理业务异常转发到钉钉群
*/
public void handleException(Throwable throwable) {
try {
reportToDingTalk(throwable.getStackTrace(), throwable.getClass().getName(), getSimpleDescribe(throwable));
} catch (Throwable throwable1) {
}
}
/**
* 把ERROR级别的log转发到钉钉群
*
* @param eventObject
*/
private void handLoggingEvent(LoggingEvent eventObject) {
if (eventObject.getLevel() != Level.ERROR || "emailLogger".equals(eventObject.getLoggerName())) {
return;
}
String msg;
try {
msg = eventObject.getFormattedMessage();
} catch (Throwable throwable) {
msg = eventObject.getMessage();
}
String className = "";
if (eventObject.getThrowableProxy() != null) {
msg = msg + "," + eventObject.getThrowableProxy().getMessage();
className = eventObject.getThrowableProxy().getClassName();
}
reportToDingTalk(eventObject.getCallerData(), className, msg);
}
/**
* @param stackTraceElements 上下文堆栈
* @param className 异常类型
* @param message 异常描述
* @return
*/
private void reportToDingTalk(StackTraceElement[] stackTraceElements, String className, String message) {
if (stackTraceElements == null || stackTraceElements.length == 0) {
return;
}
String msg = "service_error_md5_" + stackTraceElements[0].toString() + "_" + stackTraceElements.length;
String errorMd5 = Md5Util.md5(msg);
ReportDetail detail = cacheOperation.load(errorMd5);
if (detail == null) {
detail = new ReportDetail();
detail.setCount(0);
detail.setLastSendTime(DateUtil.dateTime2String(LocalDateTime.now()));
}
try {
java.time.Duration duration = java.time.Duration.between(DateUtil.string2DateTime(detail.getLastSendTime()), LocalDateTime.now());
detail.setCount(detail.getCount() + 1);
//不是首次出现异常或者距离上次发送少于1分钟 ,利用配置中心Apollo,实现动态配置相同异常出现间隔
Config dingtalk = ConfigService.getConfig("dingtalk");
Integer reportInterval = dingtalk.getIntProperty("reportInterval", 60);
if (detail.getCount() > 1 && duration.toMillis() < reportInterval * 1000) {
return;
} else {
TokenUser user = RequestData.getTokenUser();
HttpServletRequest httpServletRequest = RequestData.getRequest();
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("\n发送时间: " + DateUtil.dateTime2String(LocalDateTime.now()));
stringBuffer.append("\n请求编号: ");
stringBuffer.append(RequestData.getRequestId());
stringBuffer.append("\n主机名称: ");
stringBuffer.append(IpUtil.getHostName());
stringBuffer.append("\n城市编码: ");
stringBuffer.append(user == null ? "未知" : user.getAreaCode());
stringBuffer.append("\n操作人员: ");
stringBuffer.append(user == null ? "未知" : user.getUserName() + "(" + user.getUserId() + ")");
if (httpServletRequest != null) {
stringBuffer.append("\n异常路径: ");
stringBuffer.append(httpServletRequest.getRequestURI());
}
stringBuffer.append("\n异常预览: ");
if (StringUtil.isNotEmpty(className)) {
stringBuffer.append("\n>" + className);
}
stringBuffer.append("\n>" + message);
stringBuffer.append("\n\n[查看详情]");
String errorId = RequestData.getRequestId();
if (StringUtil.isEmpty(errorId) || "unknown-requestId".equals(errorId)) {
errorId = BusinessIdUtil.generateBizId();
}
cacheOperation.save(errorId, requestInfoAdvice.getRequestInfo() + "\n" + message + "\n" + stackTraceElementToString(stackTraceElements), 3600 * 24 * 3);
String detailUrl = "https://****.com/call/api/debug/errorDetail?errorId=" + errorId;
if (StringUtil.isNotEmpty(Util.runEvn) && !"master".equals(Util.runEvn) ) {
detailUrl = "https://" + Util.runEvn + "-**.com/call/api/debug/errorDetail?errorId=" + errorId;
}
//开发本机调试
if (DeveloperUtil.isLocalDebug()) {
detailUrl = "http://localhost:8065/**/errorDetail?errorId=" + errorId;
}
stringBuffer.append("(" + detailUrl + ")");
if (detail.getCount() > 1) {
stringBuffer.append("\n###### <font color=#ff0000>");
stringBuffer.append(formatDateTime(DateUtil.string2DateTime(detail.getLastSendTime())));
stringBuffer.append("也出现过,");
stringBuffer.append(" 共出现了" + detail.getCount() + "次");
stringBuffer.append("</font>");
}
boolean result = dingTalkError.sendMarkDownMsg(stringBuffer.toString().replace("\n", "\n\n"), null);
//记录最后出现次数
if (result) {
detail.setLastSendTime(DateUtil.dateTime2String(LocalDateTime.now()));
}
}
} catch (Throwable throwable) {
throwable.printStackTrace();
} finally {
try {
//缓存异常的堆栈信息
cacheOperation.save(errorMd5, detail);
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
}
/**
* 堆栈信息转成字符串
*
* @param stackTraceElements
* @return
*/
private String stackTraceElementToString(StackTraceElement[] stackTraceElements) {
StringBuffer stringBuffer = new StringBuffer();
if (stackTraceElements != null && stackTraceElements.length > 0) {
for (int i = 0; i < stackTraceElements.length; i++) {
stringBuffer.append(stackTraceElements[i] + "\n");
}
}
return stringBuffer.toString();
}
/**
* 获取异常简短描述
*
* @param throwable
* @return
*/
public String getSimpleDescribe(Throwable throwable) {
if (throwable instanceof RuntimeException) {
String className = throwable.getClass().getName();
String message = throwable.getMessage();
if (StringUtil.isNotEmpty(message) &&
message.contains("command denied to user")) {
//需要刷数据的时候为了避免停服 DBA把帐号权限改成只读的 等刷之后再改正常权限
return "系统维护中,当前只能进行查看操作,请稍等再试!";
} else if (StringUtil.isNotEmpty(message) && message.contains("BadSqlGrammarException")) {
return "sql异常!";
} else if (StringUtil.isNotEmpty(message) && className.startsWith("com.izk")) {
//只转发业务异常
int startIndex = message.indexOf(":", 0);
int endIndex = message.indexOf('\n', 0);
if (startIndex != -1 && endIndex != -1 && endIndex > startIndex) {
return message.substring(startIndex + 1, endIndex).trim();
} else {
return message;
}
}
}
return throwable.getMessage();
}
/**
* 格式化显示时间
*
* @param localDateTime
* @return
*/
public static String formatDateTime(LocalDateTime localDateTime) {
java.time.Duration duration = java.time.Duration.between(localDateTime, LocalDateTime.now());
if (duration.toMillis() < 60 * 1000) {
return duration.getSeconds() + "秒前";
} else if (duration.toMinutes() < 60) {
return duration.toMinutes() + "分钟前";
} else if (duration.toHours() < 24) {
return duration.toHours() + "小时前";
} else if (duration.toDays() < 28) {
return duration.toDays() + "天前";
} else if (duration.toDays() < 365) {
return duration.toDays() / 30 + "月前";
} else {
return duration.toDays() / 365 + "年前";
}
}
}
拦截全局异常,调用error异常的处理类
@ControllerAdvice
public class CallControllerExceptionHandler {
private static Logger logger = LoggerFactory.getLogger(CallControllerExceptionHandler.class);
@Resource
private ErrorToDingtalkService errorToDingtalkService;
@Resource
private RequestInfoAdvice requestInfoAdvice;
@ExceptionHandler(value = Exception.class)
@ResponseBody
public Object exceptionHandler(Exception exception) {
//此处的error级别会自动触发ErrorToDingtalkService中对error日志的处理
logger.error("system error:{}", requestInfoAdvice.getRequestInfo(), exception);
if (Strings.isNullOrEmpty(exception.getMessage())
|| !(exception instanceof ClientAbortException
|| exception instanceof MethodArgumentTypeMismatchException
|| exception instanceof HttpMediaTypeNotAcceptableException
|| exception instanceof HttpRequestMethodNotSupportedException)) {
errorToDingtalkService.handleException(exception);
}
return JsonData.error("系统异常,请联系管理员。");
}
}
在项目中添加错误信息查看的controller,用于查看错误详细
@Slf4j
@RestController
@RequestMapping(value = {"/debug"})
public class DebugController {
@Resource
private ICacheOperation cacheOperation;
@GetMapping(value = "/errorDetail")
public void changeTime(String errorId, Writer writer) {
long reslut = cacheOperation.setnx(("/errorDetail").getBytes(), Constants.ACCESS_LIMIT_WAIT_TIME, "1".getBytes());
if (reslut <= 0) {
return;
}
String errorDetail = cacheOperation.load(errorId);
StringBuilder sbHtml = new StringBuilder();
sbHtml.append("<!doctype html><html><head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">");
sbHtml.append("<title>异常详情</title></head><body>");
if (StringUtil.isNotEmpty(errorDetail)) {
String[] strs = errorDetail.split("\n");
for (String line : strs) {
//将业务异常的显示加红
line = line.replace("\t", " ");
if (line.contains("com.**")) {
sbHtml.append("<font color ='red'>");
sbHtml.append(line);
sbHtml.append("</font>");
} else {
sbHtml.append(line);
}
sbHtml.append("<br>");
}
} else {
sbHtml.append("<font color ='red'>");
sbHtml.append("异常信息获取异常,异常编号:" + errorId + ",请查看日志");
sbHtml.append("</font>");
}
sbHtml.append("<br><br><br>");
sbHtml.append("</body></html>");
try {
writer.write(sbHtml.toString());
} catch (IOException e) {
log.error(e.getMessage());
}
}
}
效果展示
点击上方标红查看详情即展示
总结
关于钉钉发送消息的工具类封装,之后会在博客中展示。其实这个问题挺好解决的,但是最重要的不去想,相比程序的实现,最难的是能有思路。除了提升变成技能,更重要的要多看,多想,真正的做到解决痛点问题。