服務端接口日志打印的幾種方法

服務端接口日志打印的幾種方法

一個服務上線對外提供服務時,接口日志打印是現網運維一個必不可缺的部分。今天這篇文章,主要講解一個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閱讀 228,505評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,556評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,463評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,009評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,778評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,218評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,281評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,436評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,969評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,795評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,993評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,537評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,229評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,659評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,917評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,687評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,990評論 2 374

推薦閱讀更多精彩內容