日志管理
# 关于日志管理
日志的重要性不言而喻,对于一个后台系统来说,我们需要对一些敏感操作进行记录,比如修改
、删除
操作。有时候也需要去记录一些用户的操作:比如登录操作
、重置密码
操作等。而随着系统业务的扩展,我们需要对很多模块进行日志记录,这时候单独对一个业务模块进行日志记录就不太行了,所以我们需要通过AOP
的方式随时对某一个业务进行日志记录,进而实现系统的日志管理模块。
# AOP自定义注解
我们想要的效果是在需要记录日志的方法加一个注解,比如现在我们要记录用户的登录操作
,当用户登录成功的时候,记录INFO
日志,当用户登录失败的时候,记录ERROR
日志。这时候我们只需要在登录相关业务模块添加我们的自定义注解即可,这时候登录这个业务模块,如果成功,那么就会自动记录INFO
日志,如果失败,就会自动记录ERROR
日志。
# 相关依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.16</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
除了AOP以外,其他依赖是为了后续将日志存入数据库,对日志进行管理。
# 日志我们需要记录那些?
作为记录操作的日志,我们应该需要记录请求时间
、请求耗时
、操作用户
、方法
、参数
、日志描述
、日志类型
等,同时如果是ERROR
日志,我们还需要记录异常信息。这里给出一个大致的设计,具体日志的设计还需要根据业务逻辑来设计。
CREATE TABLE `sys_log` (
`log_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`description` varchar(255) DEFAULT NULL,
`log_type` varchar(255) DEFAULT NULL,
`method` varchar(255) DEFAULT NULL,
`params` text,
`request_ip` varchar(255) DEFAULT NULL,
`time` bigint(20) DEFAULT NULL,
`username` varchar(255) DEFAULT NULL,
`address` varchar(255) DEFAULT NULL,
`browser` varchar(255) DEFAULT NULL,
`exception_detail` text,
`create_time` datetime DEFAULT NULL,
`platform` varchar(255) DEFAULT NULL,
PRIMARY KEY (`log_id`) USING BTREE,
KEY `log_create_time_index` (`create_time`),
KEY `inx_log_type` (`log_type`)
) ENGINE=InnoDB AUTO_INCREMENT=3594 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='系统日志';
日志实体参考如下:
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@TableName("sys_log")
public class Log implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "log_id", type= IdType.AUTO)
private Long id;
private String description;
private String logType;
private String method;
private String params;
private String requestIp;
private Long time;
private String username;
private String address;
private String browser;
private String platform;
private byte[] exceptionDetail;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
public Log(String logType, Long time) {
this.logType = logType;
this.time = time;
}
}
# 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
String value() default "";
}
- @Target(ElementType.METHOD):该注解用于方法
- @Retention(RetentionPolicy.RUNTIME):该注解的生命周期
- @interface Log: 该注解的名称
- String value() default "": 用来记录日志的描述
# 新建切面类
@Aspect
@Component
@Slf4j
public class AopLog {
@Autowired
private LogService logService;
// 用于后续请求的耗时
ThreadLocal<Long> currentTime = new ThreadLocal<>();
// 后续代码参考如下
}
# 配置切入点
/**
* 配置切入点
*/
@Pointcut("@annotation(com.xu.logaop.annotation.Log)")
public void logPointcut() {
// 该方法无方法体,主要为了让同类中其他方法使用此切入点
}
# 配置环绕通知
/**
* 配置环绕通知,使用在方法logPointcut()上注册的切入点
*
* @param point join point for advice
*/
@Around("logPointcut()")
public Object logAround(ProceedingJoinPoint point) throws Throwable {
Object result;
// 设置开始时间
currentTime.set(System.currentTimeMillis());
result = point.proceed();
// 初始化一个INFO类型的log
Log log = new Log("INFO",System.currentTimeMillis() - currentTime.get());
currentTime.remove();
HttpServletRequest request = RequestHolder.getHttpServletRequest();
// 用户名根据当前登录的得到
logService.save("admin", getBrowser(request), getIp(request), getPlatform(request), point, log);
return result;
}
# 配置异常通知
/**
* 配置异常通知
*
* @param point join point for advice
* @param e exception
*/
@AfterThrowing(pointcut = "logPointcut()", throwing = "e")
public void logAfterThrowing(JoinPoint point, Throwable e) {
// 初始化一个ERROR类型的log
Log log = new Log("ERROR", System.currentTimeMillis() - currentTime.get());
currentTime.remove();
log.setExceptionDetail(ThrowableUtil.getStackTrace(e).getBytes());
HttpServletRequest request = RequestHolder.getHttpServletRequest();
logService.save("admin", getBrowser(request), getIp(request), getPlatform(request), (ProceedingJoinPoint)point, log);
}
public class ThrowableUtil {
/**
* 获取堆栈信息
*/
public static String getStackTrace(Throwable throwable){
StringWriter sw = new StringWriter();
try (PrintWriter pw = new PrintWriter(sw)) {
throwable.printStackTrace(pw);
return sw.toString();
}
}
}
# 相关CRUD操作
这里我们采用MybatisPlus
来进行对日志存储。
Mapper
@Mapper
public interface LogMapper extends BaseMapper<Log> {
}
Service
public interface LogService extends IService<Log> {
/**
* 保存日志数据
* @param username 用户
* @param browser 浏览器
* @param ip 请求IP
* @param os 操作系统
* @param point
* @param log 日志实体
*/
@Async
void save(String username, String browser, String ip, String os, ProceedingJoinPoint point, Log log);
}
@Service
@Slf4j
public class LogServiceImpl extends ServiceImpl<LogMapper, Log> implements LogService {
@Override
public void save(String username, String browser, String ip, String os, ProceedingJoinPoint point, Log log) {
// 提前构建好这个日志的描述
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
com.xu.logaop.annotation.Log aopLog = method.getAnnotation(com.xu.logaop.annotation.Log.class);
// 方法路径
String methodName = point.getTarget().getClass().getName() + "." + signature.getName() + "()";
// 描述
if (log != null) {
log.setDescription(aopLog.value());
}
assert log != null;
log.setRequestIp(ip);
log.setAddress(getCityInfo(ip));
log.setMethod(methodName);
log.setUsername(username);
log.setParams(getParameter(method, point.getArgs()));
log.setBrowser(browser);
log.setPlatform(os);
if (log.getId() == null) {
save(log);
} else {
updateById(log);
}
}
}
在存储日志的时候,最重要的就是拿到我们自定义的日志描述
// 提前构建好这个日志的描述
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
com.xu.logaop.annotation.Log aopLog = method.getAnnotation(com.xu.logaop.annotation.Log.class);
# 其他参数获取
# HttpServletRequest
public class RequestHolder {
public static HttpServletRequest getHttpServletRequest() {
// 得到request
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
return request;
}
}
# IP地址
/**
* 获取ip地址
*/
public static String getIp(HttpServletRequest request) {
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
String comma = ",";
String localhost = "127.0.0.1";
if (ip.contains(comma)) {
ip = ip.split(",")[0];
}
if (localhost.equals(ip)) {
// 获取本机真正的ip地址
try {
ip = InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
log.error(e.getMessage(), e);
}
}
return ip;
}
# UserAgent
获取UserAgent
是为了方便获取游览器
、操作平台
等数据。
/**
* 获得UserAgent
* @param request HttpServletRequest
* @return UserAgent
*/
public static UserAgent getUserAgent(HttpServletRequest request) {
return UserAgentUtil.parse(request.getHeader("User-Agent"));
}
# 游览器
/**
* 获得游览器
* @param request HttpServletRequest
* @return String
*/
public static String getBrowser(HttpServletRequest request) {
// 得到UserAgent
UserAgent userAgent = getUserAgent(request);
return userAgent.getBrowser().toString().concat(" " + userAgent.getVersion());
}
# 操作平台
/**
* 获得操作平台
* @param request HttpServletRequest
* @return String
*/
public static String getPlatform(HttpServletRequest request) {
// 得到UserAgent
UserAgent userAgent = getUserAgent(request);
return userAgent.getPlatform().toString();
}
# 操作参数
/**
* 根据方法和传入的参数获取请求参数
*/
private String getParameter(Method method, Object[] args) {
List<Object> argList = new ArrayList<>();
Parameter[] parameters = method.getParameters();
for (int i = 0; i < parameters.length; i++) {
//将RequestBody注解修饰的参数作为请求参数
RequestBody requestBody = parameters[i].getAnnotation(RequestBody.class);
if (requestBody != null) {
argList.add(args[i]);
}
//将RequestParam注解修饰的参数作为请求参数
RequestParam requestParam = parameters[i].getAnnotation(RequestParam.class);
if (requestParam != null) {
Map<String, Object> map = new HashMap<>();
String key = parameters[i].getName();
if (!StrUtil.isEmpty(requestParam.value())) {
key = requestParam.value();
}
map.put(key, args[i]);
argList.add(map);
}
}
if (argList.size() == 0) {
return "";
}
return argList.size() == 1 ? JSONUtil.toJsonStr(argList.get(0)) : JSONUtil.toJsonStr(argList);
}
# 地址
这里采用的是高德IP定位API,网上有很多通过IP地址获取地址的方法,但是觉得高德返回API比较简洁,所以选择了高德。这里的代码仅供参考:
/**
* 根据IP获得
* @return
*/
private String getCityInfo(String ip) {
try {
// 优先调用高德API
JSONObject jsonObject = JSONUtil.parseObj(HttpUtil.get(String.format(ApiConst.GaoDe.IP_URL, ip)));
if ("[]".equals(jsonObject.get("city", String.class)))
{
return jsonObject.get("province", String.class);
}
return jsonObject.get("province", String.class) + jsonObject.get("city", String.class);
} catch (Exception e) {
log.error(e.getMessage());
// 异常,默认设置为空
return "";
}
}
相关文档:高德Web服务API文档 (opens new window)
# 实际演示
下面我们通过一个简单的小例子,来对我们自定义的日志注解来进行测试。
@Slf4j
@RestController
public class TestController {
@Log("消息测试")
@GetMapping("/test/send")
public String testError(@RequestParam(value = "msg") String msg) throws Exception {
if ("错误消息".equals(msg)) {
throw new Exception("错误消息");
} else {
return msg;
}
}
}
当我们的发送一般消息的时候,这时候就只会记录我们INFO
日志,而当我们发送操作错误消息的时候,就会记录ERROR
日志。
# 正确操作
# 错误操作
这样我们就利用AOP实现了自定义日志注解,后续什么模块当我们需要记录日志的时候,我们只需要加上自定义的注解即可完成日志的记录。
# 参考
https://zhuanlan.zhihu.com/p/143434806
https://el-admin.vip/
https://github.com/xkcoding/spring-boot-demo/tree/master/demo-log-aop