阅读本文可能会解决的问题:
① AOP简单了解
② AOP实现日志管理
③ springboot+logback整合
④ logback日志入库+文件记录
⑤ ...
七月份的尾巴,巴拉巴拉,最近很忙每天11点下班已经成了常态,刚看了个开头的《三体》也搁一边无暇顾及,有时候中午赶着出来吃个饭又屁颠回去加班干活。一到深夜就在想这样一种生活状态是不是算病态,因为感觉完全不合理。但是面对代码的时候又很兴奋,一开干就根本停下来,惶惶终日却又自我满足,实在是无奈...
不废话了说主题,最近项目需要一个日志功能,也是大家实在无法忍受没log调试的痛苦遂将此重任交给我,还是springboot还是jpa,下面就说下和logback的整合和我遇到的一些坑。
看回复大家貌似不太清楚应用的场景,简单说下:
环境:
Springboot + jpa + logback
其实老早就在用但是一直没怎么实际接触过,像shiro就是典型的AOP实现。通过注解实现全局拦截处理在实际的应用中很常见。
对于AOP只要知道这几点就好:
1,spring最核心的两个功能是aop和ioc,即面向切面,控制反转。
2,aop的实现是:通过切入点将切面注入业务
3,通俗说:很多业务叠在一起,需要一个针对几乎全部业务统一处理的这样一个过程,aop就是将业务一刀切开,在业务侧面引入统一处理机制。
所以说日志系统非常符合aop这样一种设计方式,另外还有权限系统包括token校验等也同样适用。
下面的代码部分参考了这里,在其基础上增加和优化了一些内容,具体看后面说明
1,首先我们要知道的是通过aop方式进行日志处理我们需要建立自定义注解,因为最终我们是通过在方法和类上加入注解实现面向切面编程的日志操作。
(1) 先定义@LogEvent注解:
import java.lang.annotation.*;
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface LogEvent {
ModuleType module() default ModuleType.DEFAULT; // 日志所属的模块
EventType event() default EventType.DEFAULT; // 日志事件类型
String infos() default ""; // 描述信息
}
使用:在类或方法上加入 @LogEvent(module = ModuleType.DEFAULT)
示例:
@LogEvent(module = ModuleType.DEFAULT, event = EventType.ADD, infos = "为用户充值")
注解引申:该注解需要定义内容:日志模块、日志事件、描述信息,这三项在类和方法上非必须,可省略,最终在存库时会参考这三个参数以最为日志内容记录,所以建议别怕麻烦都写上,需要注意的是类上加入此注解是为在方法上加入此注解提供默认参考值,即在方法上此注解未加入参数则会自动获取类上的该注解内容以后续保存。以下为该注解内容
① ModuleType模块类型为枚举型,可以根据实际业务增加业务模块
/**
* 模块类型
*/
public enum ModuleType {
DEFAULT("默认值"), // 默认值
CONTACTS("通讯录"),// 通讯录模块
private ModuleType(String index){
this.module = index;
}
private String module;
public String getModule(){
return this.module;
}
}
② EventType事件类型为枚举型,可以根据实际业务增加事件类型
/**
* 事件类型
*/
public enum EventType {
DEFAULT("默认", "default"), ADD("添加", "add"), FIND("查询", "find"), UPDATE("更新", "update"), DELETE_SINGLE("删除", "delete-single"),
LOGIN("登录","login"),LOGIN_OUT("注销","login_out");
private EventType(String index, String name){
this.name = name;
this.event = index;
}
private String event;
private String name;
public String getEvent(){
return this.event;
}
public String getName() {
return name;
}
}
③ infos描述信息类型为字符串,用以定义方法/类完成的具体业务内容,默认为空。
(2) 定义@LogEnable注解:
import java.lang.annotation.*;
/**
* 日志开关
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface LogEnable {
/**
* 如果为true,则类下面的LogEvent启作用,否则忽略
* @return
*/
boolean logEnable() default true;
}
使用:在类上加入此注解@LogEnable以开启@LogEvent注解生效,因此,此注解为总开关,决定是否记录日志
示例:加在类上即可,需要在@LogEvent注解之前
(3)定义@LogKey注解
import java.lang.annotation.*;
/**
* 此注解可以注解到成员变量,也可以注解到方法的参数上
*/
@Target({ElementType.FIELD,ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogKey {
String keyName() default ""; // key的名称
boolean isUserId() default false; // 此字段是否是本次操作的userId,这里略
boolean isLog() default true; // 是否加入到日志中
boolean isClass() default false; // 是否类对象
}
使用:在方法的参数或者成员变量上加入此注解@LogKey
示例1(参数为对象):
@RequestMapping(value="/getToken",method= RequestMethod.POST, produces="application/json;charset=UTF-8")
@ResponseBody
@LogEvent(module = ModuleType.TOKEN, event = EventType.getToken, infos = "getToken") // 添加日志标识
public Map getToken(@LogKey(keyName = "user", isClass = true) UserInfo user){
...
}
注解引申:该注解需要定义内容:参数名称keyName、参数是否加入日志isLog、参数是否为类对象isClass,这三项可根据自己的方法参数自己改动。原博中说这个注解在类上可用是错误的,请大家注意。还有在此注解中我加入了isClass参数,用以判断输入参数是否为对象,为true则会序列化此对象最终存库。
示例2(参数为非对象)
@LogEvent(module = ModuleType.DEFAULT, event = EventType.ADD, infos = "为用户充值")
public int chargeMoney(@LogKey(keyName = "sum") double sum,
@LogKey(keyName = "remarks") String remarks,
@LogKey(keyName = "updateDate") Date updateDate,
@LogKey(keyName = "uid") String uid,
@LogKey(keyName = "price") double price) {
// TODO Auto-generated method stub
...
}
至此注解部分全部完成了,接下来需要完成aop的切面实现逻辑
2,实现AOP
(1)首先为了方便后续对日志操作,需要一个日志实体(节省文本已省略get/set,自行添加)
/**
* 日志信息类
*/
@Table(name="sys_log")
@Entity
public class LogAdmModel {
@Id
@GeneratedValue
private Long id;
private String userId; // 操作用户
private String userName;
private String admModel; // 模块
private String admEvent; // 操作
private Date createDate = new Date(); //创建时间
@Lob
private String admOptContent; // 操作内容
private String targetMethod; // 目标方法名
private String targetClass; // 目标方法所属类的类名
private String errorMsg; // 错误信息
private String infos; // 备注
}
注意:操作内容admOptContent字段我设置为大文本,因为后续参数增多以及对象序列化内容过大会导致存不下报错,所以该字段加入@Lob注解,后续我实例化存库所以都有指定表明和标注实体类注解。
(2)对应的我们需要完成Service、ServiceImpl、Dao的代码(持久化我使用Jpa)
① LogManager
import com.base.entity.LogAdmModel;
/**
* 日志处理模块
* 1. 可以将日志存入数据
* 2. 可以将日志发送到开中间件,如果redis, mq等等
*/
public interface LogManager {
/**
* 日志处理模块
* @param paramLogAdmBean
*/
void dealLog(LogAdmModel paramLogAdmBean);
/**
* 打印异常信息
* @param e
*/
String printException(Exception e);
}
包括处理日志接口和打印异常接口(打印异常接口非必须,写这个是为了后面我方便操作Exception)
② LogManagerImpl
/**
* 将日志存入数据库
*/
@Service
@Component
public class LogManagerImpl implements LogManager {
@Autowired
LogManagerDao dBLogManagerDao;
@Override
public void dealLog(LogAdmModel paramLogAdmBean) {
UserInfo user = (UserInfo) SecurityUtils.getSubject().getSession().getAttribute("user");
System.out.println("将日志存入数据库,日志内容如下: " + JSON.toJSONString(paramLogAdmBean));
if(user!=null){
paramLogAdmBean.setUserId(String.valueOf(user.getUid()));
paramLogAdmBean.setUserName(user.getUsername());
}
dBLogManagerDao.save(paramLogAdmBean);
}
@Override
public String printException(Exception e) {
StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw,true));
e.getMessage();
String str = sw.toString();
return str;
}
}
包括处理日志操作和打印异常的实现方法
③ LogManagerDao
import com.base.entity.LogAdmModel;
import org.springframework.data.repository.CrudRepository;
import javax.transaction.Transactional;
@Transactional
public interface LogManagerDao extends CrudRepository {
}
jpa的dao只要实现jpa接口即可
(3)LogInfoGeneration日志注解处理类
import com.alibaba.fastjson.JSONObject;
import com.base.common.annotation.LogKey;
import com.base.controller.BaseController;
import com.base.entity.LogAdmModel;
import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
@Component
public class LogInfoGeneration extends BaseController{
public void processingManagerLogMessage(ProceedingJoinPoint jp, LogAdmModel logBean, Method method) {
Object[] args = jp.getArgs();
if(args.length > 0){
JSONObject msgJson = new JSONObject();
// 获取方法上参数的注解
Annotation[][] methodAnnotations = method.getParameterAnnotations();
for(int i = 0; i < args.length; i++){
Object arg = args[i];
// 如果参数被 LogKey 注解了,则直接返回内容
if(checkArgAnnotationWithIsLogKey(arg, i, methodAnnotations, msgJson)){
continue;
}
Field[] fs = arg.getClass().getDeclaredFields();
for (Field f : fs) {
Annotation[] ans = f.getAnnotations();
for (Annotation an : ans) {
if ((an instanceof LogKey) && (((LogKey) an).isLog())) {
String fieldName = f.getName();
// 如果注解的有定义keyName,则覆盖对象成员变量名称
String fieldNameLogkey = ((LogKey)an).keyName();
if(!StringUtils.isEmpty(fieldNameLogkey)){
fieldName = fieldNameLogkey;
}
try {
f.setAccessible(true);
Object fieldValue = f.get(arg);
msgJson.put(fieldName, fieldValue);
} catch (IllegalAccessException e) {
e.printStackTrace();
} finally {
f.setAccessible(false);
}
}
}
}
logBean.setAdmOptContent(msgJson.toJSONString());
}
}
}
/**
* 如果方法参数被Logkey注解,则将获取整个类的信息
* @param index
* @param methodAnnotations
* @param msgJson
* @return
*/
private boolean checkArgAnnotationWithIsLogKey(Object arg, int index, Annotation[][] methodAnnotations, JSONObject msgJson) {
for(Annotation annotation : methodAnnotations[index]){
if(annotation instanceof LogKey){
LogKey logKey = ((LogKey)annotation);
if(logKey.isLog()){
String keyName = logKey.keyName();
if(logKey.isClass()){
try {
net.sf.json.JSONObject json = net.sf.json.JSONObject.fromObject(arg);
msgJson.put(keyName, json.toString());
}catch (Exception e){
StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw,true));
logger.error(sw.toString());
}
}else{
msgJson.put(keyName, arg.toString());
}
}
break;
}
}
return false;
}
}
注:BaseController类我只是引用进来logger需要打印一些信息,非必须。
(4)配置AOP
import com.base.common.annotation.*;
import com.base.common.aop.LogInfoGeneration;
import com.base.entity.LogAdmModel;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.*;
@Component
@Aspect
public class LogAspect {
@Autowired
private LogInfoGeneration logInfoGeneration;
@Autowired
private LogManager ilogManager;
@Pointcut("execution(* com.base.service..*.*(..))")
public void basePoint() {
}
@Pointcut("execution(* com.business.service..*.*(..))")
public void businessPoint() {
}
@Pointcut("execution(public * com.business.controller.*.*(..))")
public void controllerPoint() {
}
@Pointcut("basePoint() || businessPoint() || controllerPoint()")
public void managerLogPoint() {
}
@Around("managerLogPoint()")
public Object aroundManagerLogPoint(ProceedingJoinPoint jp) throws Throwable {
printJoinPoint(jp);
Class target = jp.getTarget().getClass();
// 获取LogEnable
LogEnable logEnable = (LogEnable) target.getAnnotation(LogEnable.class);
if(logEnable == null || !logEnable.logEnable()){
return jp.proceed();
}
// 获取类上的LogEvent做为默认值
LogEvent logEventClass = (LogEvent) target.getAnnotation(LogEvent.class);
Method method = getInvokedMethod(jp);
if(method == null){
return jp.proceed();
}
// 获取方法上的LogEvent
LogEvent logEventMethod = method.getAnnotation(LogEvent.class);
if(logEventMethod == null){
return jp.proceed();
}
String optEvent = logEventMethod.event().getEvent();
String optModel = logEventMethod.module().getModule();
String desc = logEventMethod.infos();
if(logEventClass != null){
// 如果方法上的值为默认值,则使用全局的值进行替换
optEvent = optEvent.equals(EventType.DEFAULT) ? logEventClass.event().getEvent() : optEvent;
optModel = optModel.equals(ModuleType.DEFAULT.getModule()) ? logEventClass.module().getModule() : optModel;
}
LogAdmModel logBean = new LogAdmModel();
if((jp.getSignature().getName()).equals("dealLog")){}else{
logBean.setTargetMethod(jp.getSignature().getName());
logBean.setTargetClass(jp.getSignature().getDeclaringTypeName());
}
logBean.setAdmModel(optModel);
logBean.setAdmEvent(optEvent);
logBean.setInfos(desc);
logBean.setCreateDate(new Date());
logInfoGeneration.processingManagerLogMessage(jp,
logBean, method);
Object returnObj = jp.proceed();
if(optEvent.equals(EventType.LOGIN)){
//TODO 如果是登录,还需要根据返回值进行判断是不是成功了,如果成功了,则执行添加日志。这里判断比较简单
if(returnObj != null) {
this.ilogManager.dealLog(logBean);
}
}else {
this.ilogManager.dealLog(logBean);
}
return returnObj;
}
/**
* 打印节点信息
* @param jp
*/
private void printJoinPoint(ProceedingJoinPoint jp) {
//System.out.println("=======");
//System.out.println("目标方法名为:" + jp.getSignature().getName());
//System.out.println("目标方法所属类的简单类名:" + jp.getSignature().getDeclaringType().getSimpleName());
// jp.getSignature().getDeclaringType(): 调用类的类型,通常为接口
//System.out.println("目标方法所属类的类名:" + jp.getSignature().getDeclaringTypeName());
//System.out.println("目标方法声明类型:" + Modifier.toString(jp.getSignature().getModifiers()));
//获取传入目标方法的参数
/*Object[] args = jp.getArgs();
for (int i = 0; i < args.length; i++) {
System.out.println("第" + (i+1) + "个参数为:" + args[i]);
}*/
// jp.getTarget() 实际类,通常为jp.getSignature().getDeclaringType()的实现类
//System.out.println("被代理的对象:" + jp.getTarget());
//System.out.println("代理对象自己:" + jp.getThis());
//System.out.println("=======111");
/*for (Method method : jp.getSignature().getDeclaringType().getMethods()) {
System.out.println("==:" + method);
System.out.println("getAnnotations ==:" + Arrays.toString(method.getAnnotations()));
}*/
}
/**
* 获取请求方法
*
* @param jp
* @return
*/
public Method getInvokedMethod(JoinPoint jp) {
// 调用方法的参数
List classList = new ArrayList();
for (Object obj : jp.getArgs()) {
classList.add(obj.getClass());
// if (obj instanceof ArrayList)
// classList.add(List.class);
// else if (obj instanceof LinkedList)
// classList.add(List.class);
// else if (obj instanceof HashMap)
// classList.add(Map.class);
// else if (obj instanceof HashSet)
// classList.add(Set.class);
// else if (obj == null)
// classList.add(null);
// else {
// classList.add(obj.getClass());
// }
}
Class[] argsCls = (Class[]) classList.toArray(new Class[0]);
// 被调用方法名称
String methodName = jp.getSignature().getName();
Method method = null;
try {
method = jp.getTarget().getClass().getMethod(methodName, argsCls);
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
return method;
}
}
上面这个类是最重要的,因为这就是aop的切面实现类,@Aspect指定这是一个切面,@Pointcut指定我们的切入点,我这里配置了两个,controller层和service层,可按自己的系统自己配置切入点路径。@Around环绕通知,主要是完成对切入对象的操作。
至此已经完成了AOP日志入库的所有逻辑,最终我们调用被注解的类方法将会在表中按照规则存入如下记录:
那么这样的日志记录形式有什么应用场景呢?我认为对于我们的系统敏感操作最适用,可以在用户充值,后台内容操作都加入日志,最后将表数据传递到web页面,这样我们就能清晰明了的看到是谁在什么时候在系统哪个模块做了什么操作,这在系统安全性和系统健壮性上是一个很大的提升。接下来我们还要解决一个问题,对于一些相对不敏感的操作,比如只是日常记录用户操作等该怎么办呢?那就是logback出马要解决的问题了。
1,关于系统日志SLF4J
SLF4J是一个对于日志框架抽象,常用的有java.util.logging, log4j, logback,commons-logging
logback是log4j框架的作者开发的新一代日志框架,它效率更高、能够适应诸多的运行环境,同时天然支持SLF4J。
2,日志级别
日志级别从低到高分为TRACE < DEBUG < INFO < WARN < ERROR < FATAL
3,引入配置文件
为了将记录的日志存库需要引入以下maven依赖
ch.qos.logback
logback-core
${logback.version}
ch.qos.logback
logback-classic
${logback.version}
commons-dbcp
commons-dbcp
1.4
配置文件引入只需要在resources下加入logback-spring.xml即可,springboot会自动读取,文件名称必须一致。
1-%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger - %msg%n
GBK
RestAPI
${LOG_NAME}/${LOG_PATH}/${APPDIR}/log_error.log
${LOG_NAME}/${LOG_PATH}/${APPDIR}/error/log-error-%d{yyyy-MM-dd}.%i.log
2MB
true
===%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %logger Line:%-3L - %msg%n
utf-8
error
NEUTRAL
DENY
${LOG_NAME}/${LOG_PATH}/${APPDIR}/log_warn.log
${LOG_NAME}/${LOG_PATH}/${APPDIR}/warn/log-warn-%d{yyyy-MM-dd}.%i.log
2MB
true
===%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %logger Line:%-3L - %msg%n
utf-8
warn
ACCEPT
DENY
${LOG_NAME}/${LOG_PATH}/${APPDIR}/log_info.log
${LOG_NAME}/${LOG_PATH}/${APPDIR}/info/log-info-%d{yyyy-MM-dd}.%i.log
2MB
true
===%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %logger Line:%-3L - %msg%n
utf-8
info
NEUTRAL
DENY
com.mysql.jdbc.Driver
jdbc:mysql://localhost:3306/ussd?characterEncoding=UTF-8
root
1234
error
NEUTRAL
DENY
%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %logger Line:%-3L - %msg%n
配置说明:
(1)本地日志文件路径:
(2)开启日志:在application.properties中加入 spring.profiles.active=dev,可以再定义一个生产环境的,切换即可,主要是根据不同环境方便我们使用,配置文件中的springProfile就是了,可以根据不同环境开启不同的日志记录规则。
以上很简单,基本只要引入就能用了,启动项目会在控制台打印相关日志,C:/mysoftware/mylog下会自动生成一个日志文件夹,可以看到配置了三个级别的日志文件:
4,日志存库,我摘出来主要看以下配置信息
com.mysql.jdbc.Driver
jdbc:mysql://localhost:3306/ussd?characterEncoding=UTF-8
root
1234
error
NEUTRAL
DENY
同时还需要在数据库下手动生成日志表,执行以下SQL即可
BEGIN;
DROP TABLE IF EXISTS logging_event_property;
DROP TABLE IF EXISTS logging_event_exception;
DROP TABLE IF EXISTS logging_event;
COMMIT;
BEGIN;
CREATE TABLE logging_event
(
timestmp BIGINT NOT NULL,
formatted_message TEXT NOT NULL,
logger_name VARCHAR(254) NOT NULL,
level_string VARCHAR(254) NOT NULL,
thread_name VARCHAR(254),
reference_flag SMALLINT,
arg0 VARCHAR(254),
arg1 VARCHAR(254),
arg2 VARCHAR(254),
arg3 VARCHAR(254),
caller_filename VARCHAR(254) NOT NULL,
caller_class VARCHAR(254) NOT NULL,
caller_method VARCHAR(254) NOT NULL,
caller_line CHAR(4) NOT NULL,
event_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY
);
COMMIT;
BEGIN;
CREATE TABLE logging_event_property
(
event_id BIGINT NOT NULL,
mapped_key VARCHAR(254) NOT NULL,
mapped_value TEXT,
PRIMARY KEY(event_id, mapped_key),
FOREIGN KEY (event_id) REFERENCES logging_event(event_id)
);
COMMIT;
BEGIN;
CREATE TABLE logging_event_exception
(
event_id BIGINT NOT NULL,
i SMALLINT NOT NULL,
trace_line VARCHAR(254) NOT NULL,
PRIMARY KEY(event_id, i),
FOREIGN KEY (event_id) REFERENCES logging_event(event_id)
);
COMMIT;
(1)异常处理,为什么先说异常处理呢,因为理论上来说只要我们正常引入依赖和配置应该不会有什么错误,但是我还是遇到了问题,具体报错就不贴了,看过之后发现是找不到class的问题,然后发现配置里的class点不进去,最终发现是引入了依赖但是IDE工具没有引入项目,很奇怪,最后重新导了一遍项目正常了,接下来是另外一个异常,提示DataSource的配置信息名称有误(以上配置是我改正之后的),其实点到org.apache.commons.dbcp.BasicDataSource源码里能看到实际定义的一些配置名称,对比网上其他版本我发现有把url写成jdbcurl的,可能是因为版本的问题,所以大家留意一下,最好去看源码就不会错了。
(2)分级记录问题
现在我们配置的日志记录包括控制台输出、文件记录、数据库表记录,在这种情况下不对日志进行分级分类,可能造成控制台打印的同时文件记录了,同时表也保存了,这样非常混乱,本来是为了方便我们的日志变得更复杂了,所以我们首先就应该想好不同等级的日志要分开记录,分类标准大致是:debug调试的信息控制台输出,用户操作信息info文件记录,系统异常error等使用数据库表记录。
具体分级方法我们要用到logback的过滤器Filter
(3)Filter的使用
Filter包含三种状态:DENY,NEUTRAL,ACCEPT
返回DENY,日志将立即被抛弃不再经过其他过滤器;
返回NEUTRAL,有序列表里的下个过滤器过接着处理日志;
返回ACCEPT,日志会被立即处理,不再经过剩余过滤器
这里我简单说明下,假设error错误日志我既要记录文件,又要记录到数据库表里,那么在FILEERROR中我需要如下配置
error
NEUTRAL
DENY
onMatch配置为NEUTRAL表示符合error的日志将在这里处理但不销毁,继续由下一个appender的过滤器处理,根据我的配置先后顺序:
执行FILEERROR后下一个过滤error的是DBAPPENDER,所以最终又会传递到数据库表里过滤处理。
最终在数据库表里会记录类似如下异常日志:
最后差点忘了logger的使用,在类开头引入
protected final Logger logger = LoggerFactory.getLogger(this.getClass());
在方法中调用
//日志级别从低到高分为TRACE < DEBUG < INFO < WARN < ERROR < FATAL,如果设置为WARN,则低于WARN的信息都不会输出。
logger.trace("日志输出 trace");
logger.debug("日志输出 debug");
logger.info("日志输出 info");
logger.warn("日志输出 warn");
logger.error("日志输出 error");
本文完 2018-8-6
有问题欢迎大家留言指正