日志记录虽然不是什么难的点,并且springboot也提供了多个方式记录日志,但是要做好日志的记录,并且保证无论成功还是失败都要记录上,就要多加一些处理。
日志可以分为以下几种日志:
- 用户操作日志
- 接口调用日志(调用及被调用)
- 异常日志
- 运行日志
日志的实现方式也有以下几种:
- AOP切面
- 自定义拦截器Interceptor
- 全局处理异常日志
- 日志框架
- 定制化日志
根据不同的日志分类然后采取不同的日志实现方式,基本上就可以达到充分记录日志的要求,以下根据不同的日志类型进行示例
操作日志记录
这种日志记录的比较全面,基本上是涉及到所有的接口,所以我们可以采用AOP切面的方式
自定义注解
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author Zheng Jie
* @date 2018-11-24
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
String value() default "";
}
定义切面处理
import lombok.extern.slf4j.Slf4j;
import com.hugo.domain.SysLog;
import com.hugo.service.SysLogService;
import com.hugo.utils.RequestHolder;
import com.hugo.utils.SecurityUtils;
import com.hugo.utils.StringUtils;
import com.hugo.utils.ThrowableUtil;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
/**
* @author Zheng Jie
* @date 2018-11-24
*/
@Component
@Aspect
@Slf4j
public class LogAspect {
private final SysLogService sysLogService;
ThreadLocal<Long> currentTime = new ThreadLocal<>();
public LogAspect(SysLogService sysLogService) {
this.sysLogService = sysLogService;
}
/**
* 配置切入点
*/
@Pointcut("@annotation(com.hugo.annotation.Log)")
public void logPointcut() {
// 该方法无方法体,主要为了让同类中其他方法使用此切入点
}
/**
* 配置环绕通知,使用在方法logPointcut()上注册的切入点
*
* @param joinPoint join point for advice
*/
@Around("logPointcut()")
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
Object result;
currentTime.set(System.currentTimeMillis());
result = joinPoint.proceed();
SysLog sysLog = new SysLog("INFO",System.currentTimeMillis() - currentTime.get());
currentTime.remove();
HttpServletRequest request = RequestHolder.getHttpServletRequest();
sysLogService.save(getUsername(), StringUtils.getBrowser(request), StringUtils.getIp(request),joinPoint, sysLog);
return result;
}
/**
* 配置异常通知
*
* @param joinPoint join point for advice
* @param e exception
*/
@AfterThrowing(pointcut = "logPointcut()", throwing = "e")
public void logAfterThrowing(JoinPoint joinPoint, Throwable e) {
SysLog sysLog = new SysLog("ERROR",System.currentTimeMillis() - currentTime.get());
currentTime.remove();
sysLog.setExceptionDetail(ThrowableUtil.getStackTrace(e).getBytes());
HttpServletRequest request = RequestHolder.getHttpServletRequest();
sysLogService.save(getUsername(), StringUtils.getBrowser(request), StringUtils.getIp(request), (ProceedingJoinPoint)joinPoint, sysLog);
}
public String getUsername() {
try {
return SecurityUtils.getCurrentUsername();
}catch (Exception e){
return "";
}
}
主要有三个方法
- logPointcut(),指定切点
- logAround(),环绕通知配置
- logAfterThrowing(),异常通知配置
使用的话即可在需要记录的接口上使用注解 @Log("修改邮箱")
就可以自动记录操作日志。
异常日志记录
不管是用户操作系统异常,还是系统运行异常,通常我们都是要记录异常日志的,方便我们定位问题
操作异常日志的记录可以像上面用户操作日志记录异常配置的部分那样进行记录,但是还有些系统运行的异常,未使用
@Log("xxx")
注解的接口出现了异常同样也需要记录日志。这里推荐使用Springboot的提供的全局异常处理器。
全局异常处理
import com.hugo.exception.BadRequestException;
import com.hugo.exception.EntityExistException;
import com.hugo.exception.EntityNotFoundException;
import com.hugo.exception.ServiceException;
import lombok.extern.slf4j.Slf4j;
import com.hugo.utils.ThrowableUtil;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import static org.springframework.http.HttpStatus.*;
/**
* @author Zheng Jie
* @date 2018-11-23
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* BadCredentialsException
*/
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ApiError> badCredentialsException(BadCredentialsException e){
// 打印堆栈信息
String message = "坏的凭证".equals(e.getMessage()) ? "用户名或密码不正确" : e.getMessage();
log.error(message);
return buildResponseEntity(ApiError.error(message));
}
/**
* 处理自定义异常
*/
@ExceptionHandler(value = BadRequestException.class)
public ResponseEntity<ApiError> badRequestException(BadRequestException e) {
// 打印堆栈信息
log.error(ThrowableUtil.getStackTrace(e));
return buildResponseEntity(ApiError.error(e.getStatus(),e.getMessage()));
}
/**
* 处理 EntityExist
*/
@ExceptionHandler(value = EntityExistException.class)
public ResponseEntity<ApiError> entityExistException(EntityExistException e) {
// 打印堆栈信息
log.error(ThrowableUtil.getStackTrace(e));
return buildResponseEntity(ApiError.error(e.getMessage()));
}
/**
* 处理 EntityNotFound
*/
@ExceptionHandler(value = EntityNotFoundException.class)
public ResponseEntity<ApiError> entityNotFoundException(EntityNotFoundException e) {
// 打印堆栈信息
log.error(ThrowableUtil.getStackTrace(e));
return buildResponseEntity(ApiError.error(NOT_FOUND.value(),e.getMessage()));
}
/**
* 处理所有接口数据验证异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
// 打印堆栈信息
log.error(ThrowableUtil.getStackTrace(e));
ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
String message = objectError.getDefaultMessage();
if (objectError instanceof FieldError) {
message = ((FieldError) objectError).getField() + ": " + message;
}
return buildResponseEntity(ApiError.error(message));
}
/**
* 处理业务异常 ServiceException
*
* @return
*/
@ExceptionHandler(value = ServiceException.class)
public ResponseEntity<ApiError> serviceExceptionHandler(ServiceException e) {
// 打印堆栈信息
log.error(ThrowableUtil.getStackTrace(e));
return buildResponseEntity(ApiError.error(METHOD_NOT_ALLOWED.value(), e.getMessage()));
}
/**
* 处理所有不可知的异常
*/
@ExceptionHandler(Throwable.class)
public ResponseEntity<ApiError> handleException(Throwable e){
// 打印堆栈信息
log.error(ThrowableUtil.getStackTrace(e));
return buildResponseEntity(ApiError.error(e.getMessage()));
}
/**
* 统一返回
*/
private ResponseEntity<ApiError> buildResponseEntity(ApiError apiError) {
return new ResponseEntity<>(apiError, HttpStatus.valueOf(apiError.getStatus()));
}
}
注意:
- 需要优先判断处理的异常处理方法要放在前面,如示例中先处理坏的凭证异常,然后处理自定义异常,然后是处理不存在异常、接口数据验证异常,最后未定义的异常同意放到最后处理。顺序要处理好,否则可能记录的异常日志和预计有出入;
- 既然是全局异常处理,所有的异常包括接口的异常,最后都会经过全局异常处理返回错误状态码、消息给前端,所以对于业务代码中的异常如果使用了try...catch进行处理一定要记得再抛出,否则的话就不会被全局异常处理捕获了,而且如果使用了
@Transactional
注解实现自动回滚,如果捕获异常没有再次抛出异常是不会回滚的,切记。
接口日志
通常接口主要记录的是在与外部系统接口调用的日志,方便出现问题的时候查看调用情况,当然也可以放在用户操作日志里边然后过滤查询,单独拿出来就是为了方便维护和追溯。这种日志一般智只会涉及少量的接口,如果说用切面去实现,有点浪费了,所以我们可以自己写一个回调类自动记录日志
原理是这样: 有一个专门记录接口日志的服务,服务里有一个记录日志的方法,比如叫
saveInterfaceLog
,我们在这个方法里提供一个回调函数,回调函数里就一个执行业务逻辑的接口方法execute
,那这样我们在业务代码里直接调用saveInterfaceLog
方法,然后自定义实现execute
方法(处理业务逻辑)传给saveInterfaceLog
即可实现自动记录接口日志。
定义回调接口
/**
* @author zhangmy
* @date 2023/9/6 13:26
* @description 外部接口回调
*/
@FunctionalInterface
public interface ApiCallback<T> {
/**
* 执行业务逻辑
* @return
* @throws Exception
*/
T execute() throws Exception;
}
可以看出这是一个功能性接口,只有一个执行业务逻辑的接口 execute
,返回类型是泛型T
定义保存接口日志的服务接口
import java.util.List;
import java.util.Map;
/**
* @author zhangmy
* @date 2023/9/6 13:38
* @description 外部接口日志服务
*/
public interface ApiLogService {
/**
* 保存外部接口日志
* @param callback api回调,再回调函数里边执行业务逻辑
* @param logList 待保存日志list
* @param <T>
* @return
*/
<T> T saveInterfaceLog(ApiCallback<T> callback, List<Map<String, Object>> logList);
}
参数callback就是第一步中定义回调功能接口,当我们在调用 saveInterfaceLog
方法时自定已实现 ApiCallback
处理业务逻辑
保存接口日志的服务接口实现
import com.alibaba.fastjson.JSONObject;
import com.zone.kinglims.utils.EmptyUtil;
import com.zone.kinglims.utils.system.SysBasic;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author zhangmy
* @date 2023/9/6 13:44
* @description 外部接口日志服务实现
*/
@Service
public class ApiLogServiceImpl implements ApiLogService{
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public <T> T saveInterfaceLog(ApiCallback<T> callback, List<Map<String, Object>> logList) {
T result = null;
if (EmptyUtil.isEmpty(logList) || logList.isEmpty()) {
throw new RuntimeException("未成功记录接口日志,请联系管理员");
}
Map<String, Object> logMap = logList.get(0);
if (EmptyUtil.isEmpty(logMap.get("TYPE"))) {
throw new RuntimeException("未成功记录接口日志,请联系管理员");
}
try {
// 执行业务逻辑
result = callback.execute();
JSONObject resultObj = JSONObject.parseObject(result.toString());
//插入时间
logMap.put("INSERTTIME", SysBasic.getNowTime());
//接口结果描述
logMap.put("RESULT_INFO", result.toString());
//第一个标识
logMap.put("FLAG1", EmptyUtil.isNotEmpty(resultObj) && EmptyUtil.isNotEmpty(resultObj.getInteger("code")) ? resultObj.getInteger("code") : "");
//第二个标识
logMap.put("FLAG2", EmptyUtil.isNotEmpty(resultObj) && EmptyUtil.isNotEmpty(resultObj.getString("message")) ? resultObj.getString("message") : "");
//请求ID
logMap.put("INSTID", "");
} catch (Exception e) {
//插入时间
logMap.put("INSERTTIME", SysBasic.getNowTime());
//接口结果描述
logMap.put("RESULT_INFO", new JSONObject() {
{
put("code", 500);
put("message", SysBasic.toTranStringByObject(logMap.get("TYPE")) + "失败:" + e.getMessage());
}
}.toJSONString());
//第一个标识
logMap.put("FLAG1", 500);
//第二个标识
logMap.put("FLAG2", SysBasic.toTranStringByObject(logMap.get("TYPE")) + "失败:" + e.getMessage());
//请求ID
logMap.put("INSTID", "");
} finally {
SysBasic.addInterfaceTaskLog(jdbcTemplate, logList);
}
return result;
}
}
注意:
- 为了保证无论接口调用成功还是失败都能记录上日志,要在finally进行日志记录
SysBasic.addInterfaceTaskLog(jdbcTemplate, logList)
,并且finally里边的代码不能报错哈,报异常也是记录不上的; - 为了记录接口日志不被外部调用者的事务回滚,这里
saveInterfaceLog
需要另起事务@Transactional(propagation = Propagation.REQUIRES_NEW)
调用示例
return apiLogService.saveInterfaceLog(() -> {
// 回传样本检测状态url
String uri = dictCache.getDictValue("jd_interface_config", "push_back_sample_status");
// token
String jdToken = dictCache.getDictValue("jd_interface_config", "Authorization");
JSONObject resultObj = null;
try {
log.info("回传京东样本检测状态:{}", requestData.toJSONString());
resultObj = jdFeignService.pushBackSampleStatus(requestData.toJSONString(), jdToken, uri);
String code = resultObj.getString("code");
if (!code.equals("0000")) {
log.error("回传京东样本检测状态失败:{}", resultObj.toJSONString());
}
} catch (Exception e) {
log.error("回传京东样本检测状态异常:", e);
}
return resultObj;
}, new ArrayList<Map<String, Object>>() {{
add(logMap);
}});
可以看到callback中是用箭头函数实现的自定义处理业务逻辑的代码,及时这块代码出现异常并捕获处理了业务不会影响接口日志的保存,因为接口日志记录是新的事务,只要保证记录日志那里不报异常就行
这一套听上去是不是有点绕,当然也可以按常规逻辑实现,记录接口日志提供一个单独的方法,我在需要记录接口日志的业务代码里手动调用记录方法即可,其实也是一样的,我的这种方法是方便将业务代码统一放到callback中,不用再去找说我记录接口日志的代码应该放在哪里,两种方法都可以哈。
运行日志
运行日志包括的就比较多,比如java自身运行的日志,框架记录的日志(spring、数据库),我们自己手动打印的日志,这些日志都是帮助我们在出现问题的时候快速定位查找问题非常有用的帮助,下面举例一些常见的记录运行日志方式。
Logback
在Springboot项目中这应该是最常见的了,基本上每一个项目我们有一个配置日志输出的文件 logback.xml
<?xml version="1.0" encoding="UTF-8"?><!-- https://www.jianshu.com/p/ec8e198871d3 说明 -->
<configuration scan="true" scanPeriod="30 seconds" debug="false">
<contextName>elAdmin</contextName>
<property name="log.charset" value="utf-8" />
<property name="log.pattern" value="%contextName- %red(%d{yyyy-MM-dd HH:mm:ss}) %green([%thread]) %highlight(%-5level) %boldMagenta(%logger{36}) - %msg%n" />
<property name="log.pattern.prod" value="%contextName- %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36}:%L - %msg%n" />
<!--输出到控制台-->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${log.pattern}</pattern>
<charset>${log.charset}</charset>
</encoder>
</appender>
<appender name="console_test" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${log.pattern}</pattern>
<charset>${log.charset}</charset>
</encoder>
</appender>
<appender name="console_prod" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${log.pattern.prod}</pattern>
<charset>${log.charset}</charset>
</encoder>
</appender>
<!--普通日志输出到控制台-->
<springProfile name="dev">
<root level="info">
<appender-ref ref="console" />
</root>
<!--监控sql日志输出 -->
<logger name="jdbc.sqlonly" level="INFO" additivity="false">
<appender-ref ref="console" />
</logger>
</springProfile>
<springProfile name="test">
<root level="info">
<appender-ref ref="console_test" />
</root>
<!--监控sql日志输出 -->
<logger name="jdbc.sqlonly" level="INFO" additivity="false">
<appender-ref ref="console_test" />
</logger>
</springProfile>
<springProfile name="prod">
<root level="info">
<appender-ref ref="console_prod" />
</root>
<!--监控sql日志输出 -->
<logger name="jdbc.sqlonly" level="WARN" additivity="false">
<appender-ref ref="console_prod" />
</logger>
</springProfile>
<logger name="jdbc.resultset" level="ERROR" additivity="false">
<appender-ref ref="console" />
</logger>
<!-- 如想看到表格数据,将OFF改为INFO -->
<logger name="jdbc.resultsettable" level="OFF" additivity="false">
<appender-ref ref="console" />
</logger>
<logger name="jdbc.connection" level="OFF" additivity="false">
<appender-ref ref="console" />
</logger>
<logger name="jdbc.sqltiming" level="OFF" additivity="false">
<appender-ref ref="console" />
</logger>
<logger name="jdbc.audit" level="OFF" additivity="false">
<appender-ref ref="console" />
</logger>
</configuration>
这里边的配置就不细说了,大概就是配置什么样级别的日志(debug/info/error)的输出目的地(控制台/文件/ELK)
Slf4j
这里将slf4j提出来主要是通常我们在使用Lombock的时候是自带Slf4j,免去了我们手写 private static final Logger log = LoggerFactory.getLogger(xxx.class);
,当然这有一定的入侵性,如果没有使用Lombok,还是要老老实实写 private static final Logger log = LoggerFactory.getLogger(xxx.class);
。然后在业务代码比较关键的一些地方,比如输出一些对象值、捕获异常的时候输出到控制台,当然你也可以使用System.out().println()。
这里有一点需要稍微注意的地方,不建议在捕获到异常时通过 e.printStackTrace
直接打印日志到控制台,而是通过日志工具类,比如Slf4j的方法进行日志展示 log.error("AesUtil->encryptData", e);
。
ELK
ELK是Elasticsearch + logback + Kibana三个组件组合的方式将日志收集并展示,原理就是logback配置日志收集将日志推送到Elasticsearch,然后Kibana从Elasticsearch中读取日志数据展示到页面上,这种一般是大型项目需要这么做,这里就不做说明了。
总而言之,我们要尽量记录多的日志,以便出现问题或者查询历史记录的时候方便debug,既要多记录,还要保证不出现记录不上的问题出现。
评论区