1 日志类型

日志类型 说明 面向 示例
系统运行日志 系统运行过程中的监控日志。包含所有进出服务器的网络请求(如API请求、数据推送等)、内部模块普通日志 开发者、运维 如API请求、MQ、email推送、手机推送等
用户业务日志 需要供用户查询的业务日志 用户 如登录/登出、关键业务操作等
  • 按日志类型建立不同的日志存储库(隔离存储):不同性质、存储时长不一样
  • traceId贯穿整个链路,可以完整地体现数据流

2 日志流

3 系统运行日志

3.1 字段设计

  • 内容字符:根据“模块类型+通讯方式”定义不同的JSON格式

3.2 内容字段设计示例

3.2.1 http

基于http API全部采用JSON格式:

  • 仅记录requestContent-Type=application/json等指定类型的API请求
  • 记录response Content-Type=application/json等指定类型的响应数据,不记录其他Content-Type的响应数据(如文件下载)
{
    "method":"POST",
    "url":"/v1/user/create",
    "appId": "",
    "userId": "",
    "request":{
        "headers":{
            "<header-name1>":"value1",
            "<header-name2>":"value2",
            "<header-name3>":[
                "value3",
                "value4"
            ]
        },
        "body":"JSON字符串"
    },
    "response":{
        "headers":{
            "<header-name1>":"value1",
            "<header-name2>":"value2",
            "<header-name3>":[
                "value3",
                "value4"
            ]
        },
        "body":"JSON字符串"
    }
}
  • 实现示例:使用Spring ContentCachingResponseWrapper
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.taiwii.atthis.app.constant.GlobalConstant;
import com.taiwii.atthis.core.common.util.id.IdUtil;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.util.ContentCachingResponseWrapper;

import java.io.IOException;
import java.io.Serializable;
import java.util.*;

@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class HttpLogFilter implements Filter {
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    /** 需要记录日志的Content-Type */
    private static final List<String> MEDIA_TYPES = Arrays.asList(MediaType.TEXT_XML_VALUE,
            MediaType.APPLICATION_XML_VALUE,
            MediaType.APPLICATION_JSON_VALUE,
            MediaType.APPLICATION_JSON_UTF8_VALUE,
            MediaType.TEXT_PLAIN_VALUE);

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        if (null == response.getContentType()) {
            response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        }
        MDC.put(GlobalConstant.TRACE_ID, IdUtil.randomUUID());

        ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response);
        try {
            filterChain.doFilter(request, wrapper);
        } finally {
            saveLog(request, wrapper);
            this.copyBodyToResponse(request, wrapper);
            MDC.remove(GlobalConstant.TRACE_ID);
        }
    }

    //region common
    private void copyBodyToResponse(HttpServletRequest request, ContentCachingResponseWrapper wrapper) throws IOException {
        if (request.isAsyncStarted()) { // 支持异步场景
            request.getAsyncContext().addListener(new AsyncListener() {
                @Override
                public void onComplete(AsyncEvent event) throws IOException {
                    wrapper.copyBodyToResponse();
                }

                @Override
                public void onTimeout(AsyncEvent event) throws IOException {

                }

                @Override
                public void onError(AsyncEvent event) throws IOException {

                }

                @Override
                public void onStartAsync(AsyncEvent event) throws IOException {

                }
            });
        } else {
            wrapper.copyBodyToResponse();
        }
    }
    //endregion

    //region saveLog
    private void saveLog(HttpServletRequest request, ContentCachingResponseWrapper wrapper) {
        if (!this.needLog(request.getContentType())) {
            return;
        }

        HttpLog httpLog = new HttpLog();
        httpLog.setMethod(request.getMethod());
        httpLog.setUrl(request.getRequestURL().toString());
        httpLog.setRequest(this.getRequest(request));
        httpLog.setResponse(this.getResponse(request, wrapper));

        try {
            log.info(OBJECT_MAPPER.writeValueAsString(httpLog));
        } catch (JsonProcessingException e) {
            log.error(e.getMessage(), e);
        }
    }

    private boolean needLog(String contentType) {
        if (null != contentType) {
            contentType = contentType.replaceAll(" ", "");
        }
        if (null == contentType || !MEDIA_TYPES.contains(contentType)) {
            return false;
        }
        return true;
    }

    //region Request
    private HttpLog.Request getRequest(HttpServletRequest request) {
        String reqBody = null;
        try {
            reqBody = new String(request.getInputStream().readAllBytes(), "utf-8");
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }

        HttpLog.Request req = new HttpLog.Request();
        req.setHeaders(this.getRequestHeaders(request));
        req.setBody(reqBody);
        return req;
    }

    private Map<String, Object> getRequestHeaders(HttpServletRequest request) {
        Map<String, Object> map = new HashMap<>();
        request.getHeaderNames().asIterator().forEachRemaining((name) -> {
            Iterator<String> iterator = request.getHeaders(name).asIterator();
            List<String> list = new ArrayList<>();
            while (iterator.hasNext()) {
                list.add(iterator.next());
            }

            switch (list.size()) {
                case 0:
                    return;
                case 1:
                    map.put(name, list.get(0));
                    return;
                default:
                    map.put(name, list);
                    return;
            }
        });
        return map;
    }
    //endregion

    //region Response
    private HttpLog.Response getResponse(HttpServletRequest request, ContentCachingResponseWrapper wrapper) {
        if (!this.needLog(wrapper.getContentType())) {
            return null;
        }

        String body = null;
        try {
            body = new String(wrapper.getContentAsByteArray(), "utf-8");
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }

        HttpLog.Response resp = new HttpLog.Response();
        resp.setHeaders(this.getResponseHeaders(wrapper));
        resp.setBody(body);
        return resp;
    }

    private Map<String, Object> getResponseHeaders(HttpServletResponse response) {
        Map<String, Object> map = new HashMap<>();
        response.getHeaderNames().iterator().forEachRemaining((name) -> {
            Iterator<String> iterator = response.getHeaders(name).iterator();
            List<String> list = new ArrayList<>();
            while (iterator.hasNext()) {
                list.add(iterator.next());
            }

            switch (list.size()) {
                case 0:
                    return;
                case 1:
                    map.put(name, list.get(0));
                    return;
                default:
                    map.put(name, list);
                    return;
            }
        });
        return map;
    }
    //endregion

    @Data
    class HttpLog implements Serializable {
        private String method;
        private String url;
        private String appId;
        private String userId;
        private Request request;
        private Response response;

        @Data
        @NoArgsConstructor
        @AllArgsConstructor
        static class Request implements Serializable {
            private Map<String, Object> headers = Collections.emptyMap();
            private String body;
        }

        @Data
        @NoArgsConstructor
        @AllArgsConstructor
        static class Response implements Serializable {
            private Map<String, Object> headers = Collections.emptyMap();
            private String body;
        }
    }
    //endregion
}

3.2.2 MQ

{
	"messageId": "",
	"topic": "",
	"body": ""
}

3.2.3 内部模块

  • log4jlogback示例:
<property name="LOG_PATTERN" value="[%d{yyyy-MM-dd HH:mm:ss SSS}][%-5p][%t] - %logger%n%m%n" />
  • log4jlogback完整配置(logback-spring.xml):
<?xml version="1.0" encoding="UTF-8"?>

<configuration debug="false" scan="true">
    <springProperty scope="context" name="LOG_NAME" source="spring.application.name" defaultValue="log"/>
    <springProperty scope="context" name="LOG_PATH" source="logging.path" defaultValue="logs"/>
    <property name="LOG_PATTERN" value="[%d{yyyy-MM-dd HH:mm:ss SSS}][%-5p][%t] - %logger%n%m%n" />

    <!-- 同时基于时间和文件大小生成日志文件:每天生成一个文件,且每个文件小于100MB(压缩前),且60天内的日志,总日志大小不超过1GB(压缩后) -->
    <appender name="TIME_AND_SIZE_BASE_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/${LOG_NAME}.log</file>
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/${LOG_NAME}-%d{yyyy-MM-dd}.%i.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>7</maxHistory>
            <totalSizeCap>1GB</totalSizeCap><!-- 日志文件总占用空间大小必须同时满足maxHistory和totalSizeCap -->
        </rollingPolicy>
    </appender>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <immediateFlush>true</immediateFlush>
        </encoder>
    </appender>

    <root additivity="false" level="WARN">
        <appender-ref ref="TIME_AND_SIZE_BASE_FILE" />
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

4 用户业务日志

4.1 字段设计

4.2 实现

4.2.1 自定义注解

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Inherited
@Documented
public @interface BizLogger {
    /** 业务类型 */
    BizLogType type();

    /** 日志内容 */
    String content();
}

4.2.2 日志类型

import lombok.Getter;

@Getter
public enum BizLogType {
    LOGIN_LOGOUT("登录/登出"),
    USER("用户");

    private String value;
    private BizLogType(String value){
        this.value = value;
    }
}

4.2.3 Controller

import com.taiwii.atthis.core.bizlog.BizLog;
import com.taiwii.atthis.core.bizlog.BizLogType;
import com.taiwii.atthis.core.bizlog.aspect.BizLogger;
import com.taiwii.atthis.core.common.model.response.BizResponse;
import org.slf4j.MDC;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;
import java.util.List;

@RestController
@RequestMapping("/v1/test")
public class TestControllerV1 {

    @GetMapping("/create")
    @BizLogger(type = BizLogType.USER, content = "注册新账号【用户名={0},手机号={1}】")
    public BizResponse create() {
        List<String> bizParams = Arrays.asList("Cooper", "13800138000");
        MDC.put(BizLog.MDCKey.PARAMS, "Cooper,13800138000");
        return BizResponse.newSuccessInstance("Create SUCCESS");
    }
}

4.2.4 AOP处理日志

import com.taiwii.atthis.app.constant.GlobalConstant;
import com.taiwii.atthis.core.bizlog.BizLog;
import com.taiwii.atthis.core.bizlog.BizLogType;
import com.taiwii.atthis.core.bizlog.aspect.BizLogger;
import lombok.extern.slf4j.Slf4j;
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.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
import org.springframework.context.annotation.Configuration;

import java.text.MessageFormat;

@Slf4j
@Aspect
@Configuration
public class BizLogAOPConfig {

    @Pointcut("execution(* com.taiwii.atthis..controller..*.*(..))")
    public void pointCutMethod() {
    }

    @Around("pointCutMethod()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        Object result = null;
        long start = System.currentTimeMillis();
        BizLogger bizLogger = ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(BizLogger.class);
        try {
            result = joinPoint.proceed();
            this.saveBizLog(bizLogger, start, null);
        } catch (Exception e) {
            this.saveBizLog(bizLogger, start, e);
            throw e;
        }
        return result;
    }

    private void saveBizLog(BizLogger bizLogger, long start, Exception e) {
        if (null == bizLogger) {
            return;
        }
        BizLogType type = bizLogger.type();
        String content = bizLogger.content();
        long cost = System.currentTimeMillis() - start;
        String traceId = MDC.get(GlobalConstant.TRACE_ID);

        String[] params = MDC.get(BizLog.MDCKey.PARAMS).split(",");
        content = MessageFormat.format(content, params);

        boolean success = null == e;
        
        // traceId=6138bbd9ce1b4feda7a8136bb166e0af, type=用户, content=注册新账号【用户名=Cooper,手机号=13800138000】, cost=0 ms, success=true
        log.info(String.format("traceId=%s, type=%s, content=%s, cost=%s ms, success=%s", traceId, type.getValue(), content, cost, success));
    }
}

4.2.5 测试

curl -X GET localhost:8080/v1/test/create