服务端接口日志打印的几种方法

服务端接口日志打印的几种方法

一个服务上线对外提供服务时,接口日志打印是现网运维一个必不可缺的部分。今天这篇文章,主要讲解一个SpringWeb服务,统一处理接口日志打印的方法。

接口日志主要包含的字段包括如下几点:接口路径,接口请求时间,接口入参,接口出参。且需要针对性的对接口出入参进行数据脱敏处理。


使用AOP打印接口日志

接口日志切面选择

对于比较简单可,可以直接拦截所有的http请求,并打印所有的request.parameters。但这样不够灵活,容易将文件数据或敏感数据打印。这里,通过自定义接口日志注解的方式作为切点。

注解定义

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface InterfaceLog {

}

编写aop切面

@Aspect
@Component
public class InterfaceLogAspect {

    private static final Logger INTERFACE_LOG = LogUtil.getInterfaceLogger();


    @Pointcut("@annotation(xxx.xxx.xxx.annotation.InterfaceLog) ")
    public void log() {
    }

    @Before("log()")
    public void init(JoinPoint joinPoint) throws CException {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        // ......
    }

    @AfterReturning(returning = "rsp", pointcut = "log()")
    public void doAfterReturning(JoinPoint jp, Object rsp) throws CException {
        printNormalInterfaceLog(rsp);
    }

    @AfterThrowing(throwing = "ex", pointcut = "log()")
    public void ajaxThrow(JoinPoint jp, Exception ex) throws CException {
        printErrorInterfaceLog(ex);
    }
}

网络接口请求中的数据共享传递方案

1、ThreadLocal方案
构造定义ThreadLocal对象

public final class SystemContext {
    private static final ThreadLocal<LogInfo> LOG_CONTEXT = new ThreadLocal<LogInfo>();

    private SystemContext() {}

    public static LogInfo getLogInfo() {
        return LOG_CONTEXT.get();
    }

    public static void setLogInfo(LogInfo logInfo) {
        LOG_CONTEXT.set(logInfo);
    }
}

ThreadLocal对象使用场景
在before时将startTime设定,在afterReturn里打印日志时可以取出startTime以计算

    @Before("log()")
    public void init(JoinPoint joinPoint) throws CException {
        // LogInfo logInfo = new LogInfo (System.currentTimeMillis(),"other paramter");
        SystemContext.setLogInfo(logInfo);
    }
    private void printNormalInterfaceLog(Object rsp) {
        LogInfo logInfo =  SystemContext.getLogInfo();
        logInfo.setCostTime(System.currentTimeMillis() - logInfo.getStartTime());
        logInfo.setRsp(LogUtil.toLogString(rsp));
        INTERFACE_LOG.info(logInfo.toLogString());
    }

2、切片方法参数匹配

切片方法中,匹配一个context对象,(其作用和ThreadLocal对象类似,都可以在切片和方法中传递数据),对该对象进行数据的读写操作。

    @Before("log()  && args(context,..)")
    public void init(JoinPoint joinPoint, Context context) throws CException {
       context.setStartTime(System.currentTimeMillis());
    }

    @AfterReturning(returning = "rsp", pointcut = "log() && args(context,..)")
    public void doAfterReturning(JoinPoint jp, Object rsp, Context context) throws CException {
        printNormalInterfaceLog(context, rsp);
    }

    @AfterThrowing(throwing = "ex", pointcut = "log()&& args(context,..)")
    public void ajaxThrowss(JoinPoint jp, Exception ex, Context context) throws CException {
        printErrorInterfaceLog(context, ex);

    }

对应的接口也需要传入该参数, 也可是使用context对象里的其他加工好的数据。

    @RequestMapping(value = "/getUserInfo", method = RequestMethod.POST)
    @InterfaceLog
    @ResponseBody
    public GetUserInfoRsp getUserInfo(Context context) throws CException {
        LOG.debug("come into getUserInfo");
        GetUserInfoRsp rsp = loginService.getUserInfo(context);
        return rsp;
    }

在切面中获取接口信息

接口路径:可以通过HttpServletRequest对象获取到;
接口请求时间:可以通过上诉的数据传递方案,从threadLocal或切片参数中获取时间差;
接口出参:可以通过AfterReturn方法内的返回值直接获取到;
接口入参:可以通过上诉的数据传递方案,从threadLocal或切片参数中获取,也可以从JoinPoint参数中提取。

private Optional<Object> getRequestBodyParam(JoinPoint joinPoint){
    if (joinPoint instanceof MethodInvocationProceedingJoinPoint) {
        Signature signature = joinPoint.getSignature();
        if (signature instanceof MethodSignature) {
            MethodSignature methodSignature = (MethodSignature) signature;
            Method method = methodSignature.getMethod();
            Parameter[] methodParameters = method.getParameters();
            if (null != methodParameters
                    && Arrays.stream(methodParameters).anyMatch(p-> AnnotationUtils.findAnnotation(p, RequestBody.class) != null)) {
                return Optional.of(joinPoint.getArgs());
            }
        }
    }
    return Optional.empty();
}

作者:crick77
链接:https://ld246.com/article/1541226397969
来源:链滴
协议:CC BY-SA 4.0 https://creativecommons.org/licenses/by-sa/4.0/

Filter中打印接口日志

背景,在使用公司的微服务框架时开发web服务时,发现通过aop打印日志有诸多限制。无法在aop中获取ServletRequestAttributes对象;接口仅能接收一个入参对象,无法通过切片参数的方式传递数据。然后研究了Filter中打印接口日志的一些姿势。

实现HttpServerFilter

通过HttpServerFilter的方式,在afterReceiveRequest中获取startTime,uri,reqBody数据,在beforeSendResponse中获取rspBody、costTime。

public class InterfaceLogFilter implements HttpServerFilter {

    @Override
    public Response afterReceiveRequest(Invocation invocation, HttpServletRequestEx httpServletRequestEx) {
        long startTime = Instant.now().toEpochMilli();
        Object reqBody = FilterHelper.getReqBody(invocation);
        LogInfo logInfo =
                new LogInfo(
                        MDCUtil.getTranId(),
                        httpServletRequestEx.getPathInfo(),
                        startTime,
                        ToStringUtil.logString(reqBody));
        SystemContext.setLogInfo(logInfo);
        return null;
    }

    @Override
    public void beforeSendResponse(Invocation invocation, HttpServletResponseEx responseEx) {
        byte[] plainBodyBytes = responseEx.getBodyBytes();
        Object rspBody = FilterHelper.getRspBody(invocation, plainBodyBytes);
        logInfo.setRsp(ToStringUtil.logString(rspBody));
        logInfo.setCostTime(Instant.now().toEpochMilli() - logInfo.getStartTime());
        LOGGER.info(logInfo.toLogString());
    }
}

从Invocation对象中提取出接口入参和出参对象

这其中的难点在于如何从Invocation对象中提取出接口入参和出参对象
下面给出简单的实现说明。
获取接口入参,可以通过微服务的Swagger能力,获取到paramters,直接从invocation.getSwaggerArgument(index)中获取到入参对象
获取接口出参,同理从invocation中获取到returnType,然后反序列化出出参对象。

    public static Object getReqBody(Invocation invocation) {
        OperationMeta meta = invocation.getOperationMeta();
        List<Parameter> params = meta.getSwaggerOperation().getParameters();

        if (bodyMetaCache.get(meta.getSchemaQualifiedName()) != null) {
            return invocation.getSwaggerArgument(bodyMetaCache.get(meta.getSchemaQualifiedName()));
        }

        int index = -1;

        for (int i = 0; i < params.size(); ++i) {
            String in = ((Parameter) params.get(i)).getIn();
            if (in.equalsIgnoreCase(LOCATION_BODY)) {
                index = i;
                bodyMetaCache.put(meta.getSchemaQualifiedName(), i);
                break;
            }
        }
        if (index < 0) {
            return null;
        }
        return invocation.getSwaggerArgument(index);
    }

    public static Object getRspBody(Invocation invocation, byte[] plainBodyBytes) {
        Class<?> returnType = invocation.getOperationMeta().getMethod().getReturnType();
        Object requestBody = null;
        String bodyString = new String(plainBodyBytes, StandardCharsets.UTF_8);
        try {
            requestBody = OBJECT_MAPPER.readValue(bodyString, returnType);
        } catch (Exception e) {
            log.error("RspBody parse error");
        }

        return requestBody;
    }

通过一个统一的接口接收处理所有的请求

方案基本实现

如下,开放一个统一的对外的RestController,设定@RequestMapping(path = "/{path:.*}")通配所有接口请求,业务逻辑处理前后做接口日志打印。

/**
 * 面向端侧开放的restful风格接口
 *
 */

@RestController
@Slf4j
@RequestMapping(path = "/prefix/path")
public class AppController extends BaseController {

    @PostMapping(path = "/{path:.*}", produces = MediaType.APPLICATION_JSON_VALUE,
            consumes = MediaType.APPLICATION_JSON_VALUE)
    public BaseResponse appApi(HttpServletRequest servletRequest) {
        long begin = System.currentTimeMillis();
        BaseResponse response = null;
        ServiceHandledEvent.Builder builder = new ServiceHandledEvent.Builder(this);
        try {
            // 获取接口请求入参
            String requestBody = getRequestBody(servletRequest);
            BaseRequest request = parseBaseRequest(requestBody);
            String commandName = getCommandName(servletRequest);
            //业务逻辑处理在processCommond中进行
            response = processCommand(commandName, request);
        } catch (Exception e) {
            response = new BaseResponse();
        } finally {
            // 打印接口日志
            long end = System.currentTimeMillis();
            builder.responseTime(end - begin)   // 记录接口请求时间
                    .requestData(ToStringUtil.logString(request))   // 记录脱敏后的入参
                    .responseData(ToStringUtil.logString(response)) // 记录脱敏后的出参
                    .url(servletRequest.getPathInfo()); //记录接口路径
                    // 继续记录servletRequest的详细数据或出参的含义,以便于现网大数据监控运维
            ServiceHandledEvent event = builder.build();
            eventPushService.pushEvent(event);
            // 通过EventPushService的方式让日志记录解耦
        }
        return response;
    }
}

方案简析

这种方案,对外暴露的只有一个通配的RequestMapping,对于接口的可维护性要差一点。
具体表现在:无法自动生成swagger文件,需要手动维护接口的出入参信息,这在联合开发的情况下不利于管理;如果作为一个微服务,注册中心无法探测到其接口的变动,不便于监控。
但它的优点在于:将以往项目众多aop、filter或interceptor内的逻辑,都可以挪到RestController内进行处理。代码错误更好定位,业务流程也跟清晰。

后记

方案不是一成不变的,filter方案打印接口日志里的套路,也可以用在aop中。提取接口出参对象、数据共享传递,也可以根据具体情况用在其他场景。不同方案中的技术手段可以交错着用,具体需要看技术框架及业务场景的限制及要求。

这篇文章里分享的接口日志打印方案,相对于网上其他的方案,可能会显得繁琐。其主要原因在于所涉及的业务对接口信息要求更复杂,且对出入参有数据脱敏的要求。仅仅获取接口出入参的字符串时不够的。不然的话,索性在网关中记录所有接口也是可行的

至于日志内数据脱敏,在其他文章中会进一步分析讲解。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,265评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,078评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,852评论 0 347
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,408评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,445评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,772评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,921评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,688评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,130评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,467评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,617评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,276评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,882评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,740评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,967评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,315评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,486评论 2 348

推荐阅读更多精彩内容