服務端接口日志打印的幾種方法
一個服務上線對外提供服務時,接口日志打印是現網運維一個必不可缺的部分。今天這篇文章,主要講解一個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中。提取接口出參對象、數據共享傳遞,也可以根據具體情況用在其他場景。不同方案中的技術手段可以交錯著用,具體需要看技術框架及業務場景的限制及要求。
這篇文章里分享的接口日志打印方案,相對于網上其他的方案,可能會顯得繁瑣。其主要原因在于所涉及的業務對接口信息要求更復雜,且對出入參有數據脫敏的要求。僅僅獲取接口出入參的字符串時不夠的。不然的話,索性在網關中記錄所有接口也是可行的
至于日志內數據脫敏,在其他文章中會進一步分析講解。