Spring Boot 自定义注解+切面 实现 API 日志打印

本文介绍基于Spring Boot的自定义注解与AOP切面实现API统一日志打印,涵盖请求/响应DTO封装、字段级敏感信息脱敏、异步耗时边界处理、日志大小控制及异常保护机制。强调生产实践中日志不干扰业务、脱敏失败降级、避免敏感头泄露等关键原则,并支持TraceId/MDC链路关联。

前言

在 Spring Boot 项目中,Controller 接口通常需要记录请求参数、接口描述、响应结果和执行耗时。如果在每个 Controller 方法里手动写日志,不仅重复代码多,还容易出现日志格式不统一、敏感信息泄露和异常场景日志缺失等问题。

本文基于现有的 @ApiLog 注解与 ApilLogHandlerAdvice 切面实现,其余每个类只承担一种职责。

实现的效果:

  1. 业务方法抛出异常时,仍然记录接口结束日志和耗时。
  2. 监控上报或日志序列化失败时,不影响业务结果。
  3. 将请求日志、响应日志封装为独立 DTO。
  4. 将参数构建、响应处理、脱敏与日志输出分别封装。
  5. 控制集合、嵌套对象和字符串的日志大小。
  6. 对密码、Token、身份证号、银行卡号、手机号等常见敏感字段做字段级脱敏。
  7. 对 SseEmitter、CompletionStage 等异步返回值作出提示,避免把“返回异步对象的耗时”误认为“完整业务耗时”。

适用说明: 以下示例面向 Java 8、Spring Boot / Spring MVC 与 Jackson。示例保留原项目的 @ApiLog 包名,并将新类统一放在 com.myproject.demo.apilog 示例包下。原项目的 MonitorLogService、IgnoreLog 等自定义类型需要按真实包路径接入。若项目使用 JDK 17、Spring Boot 3,则 Servlet 包通常应由 javax.servlet 改为 jakarta.servlet。


一、整体结构

建议将代码拆分为以下职责:

类职责
ApiLog标记哪些方法需要记录 API 日志,并提供接口描述
ApiRequestLog请求日志数据模型,只负责承载请求日志字段
ApiResponseLog响应日志数据模型,只负责承载响应日志字段
ApiLogRequestBuilder从 AOP 参数、方法信息和 Servlet 请求中构建请求日志
ApiLogResponseBuilder构建成功或异常情况下的响应日志
ApiLogValueSanitizer过滤不适合记录的对象、字段脱敏、限制日志内容大小
ApiLogWriter将日志 DTO 转换为 JSON 并输出,负责处理日志写入失败
ApiLogMonitor定义监控上报接口,隔离具体监控实现
ApilLogHandlerAdvice仅负责 AOP 编排、计时和保证异常路径被记录

示例包结构如下:

com.myproject.demo
├── annotations
│   └── ApiLog.java
└── apilog
    ├── ApilLogHandlerAdvice.java
    ├── model
    │   ├── ApiRequestLog.java
    │   └── ApiResponseLog.java
    └── service
        ├── ApiLogRequestBuilder.java
        ├── ApiLogResponseBuilder.java
        ├── ApiLogValueSanitizer.java
        ├── ApiLogWriter.java
        └── ApiLogMonitor.java

这里的 service 只是为了示例清晰。也可以根据项目规范使用 support、processor 或 infrastructure 等包名。

二、定义 @ApiLog 注解

文件:ApiLog.java

package com.myproject.demo.annotations;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 标记需要记录 API 请求、响应与耗时日志的方法。
 *
 * <p>该注解通常放在 Controller 的接口方法上。切面通过注解匹配目标方法,
 * 并将注解中的 value 作为日志中的接口描述。</p>
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ApiLog {

    /**
     * 接口的可读描述,例如“查询用车订单”。
     * 未填写时,日志组件会使用 Java 方法名作为默认描述。
     */
    String value() default "";
}

RUNTIME 是必要的,因为 AOP 在运行时需要识别注解。METHOD 限制该注解只能放在方法上,能避免误用在类或字段上。

三、定义请求日志 DTO

文件:ApiRequestLog.java

package com.myproject.demo.apilog.model;

import java.io.Serializable;
import java.util.Map;

/**
 * 请求日志数据模型。
 *
 * <p>该类只负责承载数据,不负责获取请求、脱敏或输出日志,
 * 以便日志字段可以被独立测试和扩展。</p>
 */
public class ApiRequestLog implements Serializable {

    private static final long serialVersionUID = 1L;

    /** 请求关联 ID,用于将同一次调用的请求日志和响应日志关联起来。 */
    private String requestId;

    /** 接口描述,优先使用 @ApiLog.value(),否则使用方法名。 */
    private String apiName;

    /** Java 方法定位信息,格式为“全限定类名#方法名”。 */
    private String classMethod;

    /** HTTP 请求方式,例如 GET、POST。 */
    private String httpMethod;

    /** 请求 URL;默认不拼接 query string,避免意外记录查询参数中的敏感数据。 */
    private String requestUrl;

    /** 直接连接到当前应用的客户端地址。若经过代理,该地址可能是代理地址。 */
    private String remoteAddr;

    /** 参数名与经过安全处理的参数摘要。 */
    private Map<String, Object> parameters;

    /** 请求开始时间的 Unix 毫秒时间戳。 */
    private long timestamp;

    public String getRequestId() { return requestId; }
    public void setRequestId(String requestId) { this.requestId = requestId; }

    public String getApiName() { return apiName; }
    public void setApiName(String apiName) { this.apiName = apiName; }

    public String getClassMethod() { return classMethod; }
    public void setClassMethod(String classMethod) { this.classMethod = classMethod; }

    public String getHttpMethod() { return httpMethod; }
    public void setHttpMethod(String httpMethod) { this.httpMethod = httpMethod; }

    public String getRequestUrl() { return requestUrl; }
    public void setRequestUrl(String requestUrl) { this.requestUrl = requestUrl; }

    public String getRemoteAddr() { return remoteAddr; }
    public void setRemoteAddr(String remoteAddr) { this.remoteAddr = remoteAddr; }

    public Map<String, Object> getParameters() { return parameters; }
    public void setParameters(Map<String, Object> parameters) { this.parameters = parameters; }

    public long getTimestamp() { return timestamp; }
    public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
}

这里没有把 HttpServletRequest 对象直接放进 DTO,而是只提取必要的元数据。请求对象内部包含大量容器状态、请求头和输入流,不应该被直接序列化进日志。

四、定义响应日志 DTO

文件:ApiResponseLog.java

package com.myproject.demo.apilog.model;

import java.io.Serializable;

/**
 * 响应日志数据模型。
 *
 * <p>既记录正常返回,也记录业务方法抛出异常的情况。
 * 为降低敏感信息泄露风险,默认只记录异常类型,不直接记录完整异常消息。</p>
 */
public class ApiResponseLog implements Serializable {

    private static final long serialVersionUID = 1L;

    /** 与 ApiRequestLog.requestId 保持一致。 */
    private String requestId;

    /** 可读的接口描述。 */
    private String apiName;

    /** Java 方法定位信息。 */
    private String classMethod;

    /** Java 方法执行耗时,单位毫秒。 */
    private long durationMs;

    /** 是否正常完成 Java 方法调用;不等价于 HTTP 状态码一定是 2xx。 */
    private boolean methodSuccess;

    /** 如果返回类型是 ResponseEntity,则记录其 HTTP 状态码;否则为空。 */
    private Integer httpStatus;

    /** 已经过滤、脱敏和大小限制的响应摘要。 */
    private Object response;

    /** 异常类型,例如 java.lang.IllegalStateException;正常时为空。 */
    private String errorType;

    /** 响应日志构建时间的 Unix 毫秒时间戳。 */
    private long timestamp;

    public String getRequestId() { return requestId; }
    public void setRequestId(String requestId) { this.requestId = requestId; }

    public String getApiName() { return apiName; }
    public void setApiName(String apiName) { this.apiName = apiName; }

    public String getClassMethod() { return classMethod; }
    public void setClassMethod(String classMethod) { this.classMethod = classMethod; }

    public long getDurationMs() { return durationMs; }
    public void setDurationMs(long durationMs) { this.durationMs = durationMs; }

    public boolean isMethodSuccess() { return methodSuccess; }
    public void setMethodSuccess(boolean methodSuccess) { this.methodSuccess = methodSuccess; }

    public Integer getHttpStatus() { return httpStatus; }
    public void setHttpStatus(Integer httpStatus) { this.httpStatus = httpStatus; }

    public Object getResponse() { return response; }
    public void setResponse(Object response) { this.response = response; }

    public String getErrorType() { return errorType; }
    public void setErrorType(String errorType) { this.errorType = errorType; }

    public long getTimestamp() { return timestamp; }
    public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
}

注意:methodSuccess 表示 Java 方法有没有正常返回。比如业务代码正常返回了一个 ResponseEntity,但状态码是 400,那么 methodSuccess 仍可能是 true。如果项目需要统一表示 HTTP 业务成功,应另行定义业务成功字段,不能混用这两个概念。

五、实现统一参数与响应安全处理器

文件:ApiLogValueSanitizer.java

该类集中处理三件事情:

  • 忽略不适合进入日志的基础设施对象。
  • 依据字段名脱敏,而不是仅靠字符串中的数字正则猜测敏感数据。
  • 对 Map、集合、数组、嵌套 JSON 和字符串设置大小限制。
package com.myproject.demo.apilog.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Array;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * API 日志值处理器。
 *
 * <p>该类把允许记录的业务对象转换为受控的 JSON 数据结构,并执行敏感字段脱敏、深度限制和元素数量限制。</p>
 * 
 */
@Component
public class ApiLogValueSanitizer {

    /** 用于将常见手机号中间四位替换为星号;不是通用身份证/银行卡脱敏规则。 */
    private static final Pattern PHONE_PATTERN =
            Pattern.compile("(?<!\\d)(1[3-9]\\d)\\d{4}(\\d{4})(?!\\d)");

    /** 脱敏常见 Bearer 凭证,防止原始字符串响应直接返回 Token。 */
    private static final Pattern BEARER_TOKEN_PATTERN =
            Pattern.compile("(?i)\\bBearer\\s+[A-Za-z0-9._~+/=-]+");

    /** 识别外观符合 JWT 三段式结构的字符串,避免完整 JWT 被写入日志。 */
    private static final Pattern JWT_PATTERN =
            Pattern.compile("\\beyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\b");

    /** 敏感字段替换后的固定内容。 */
    private static final String MASKED = "[已脱敏]";

    /** 超过最大嵌套层数时使用的占位文本。 */
    private static final String DEPTH_LIMITED = "[内容已省略:超过最大嵌套层级]";

    /** 每个集合最多展示的元素数量。 */
    private static final int MAX_COLLECTION_PREVIEW = 5;

    /** Map 最多展示的键值对数量。 */
    private static final int MAX_MAP_ENTRIES = 20;

    /** JSON 对象最多输出的字段数量。 */
    private static final int MAX_JSON_FIELDS = 30;

    /** 最大递归深度,避免过深对象输出过多内容。 */
    private static final int MAX_DEPTH = 5;

    /** 单个字符串的最大字符数。 */
    private static final int MAX_STRING_LENGTH = 512;

    /** Jackson 的 JSON 转换器,使用 Spring Boot 自动配置的 ObjectMapper。 */
    private final ObjectMapper objectMapper;

    public ApiLogValueSanitizer(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    /**
     * 对外提供统一的日志值处理入口。
     *
     * @param value     原始参数或响应对象
     * @param fieldName 当前字段名,用于判断字段是否敏感
     * @return 可安全尝试输出到日志的数据结构或占位文本
     */
    public Object sanitize(Object value, String fieldName) {
        return sanitizeValue(value, fieldName, 0);
    }

    /**
     * 判断字段名是否属于常见敏感信息。
     *
     * <p>采用忽略大小写、下划线和连字符的方式匹配。实际项目还应依据自身 DTO 字段
     * 补充规则;不要把字段名规则当成防止泄露的唯一安全措施。</p>
     */
    public boolean isSensitiveField(String fieldName) {
        if (fieldName == null || fieldName.isEmpty()) {
            return false;
        }

        String normalized = fieldName
                .replace("_", "")
                .replace("-", "")
                .toLowerCase(Locale.ROOT);

        String[] keywords = {
                "password", "passwd", "pwd", "token", "authorization",
                "accesskey", "secret", "privatekey", "idcard", "identitycard",
                "mobile", "phone", "bankcard", "cardno", "creditcard",
                "cookie", "credential"
        };

        for (String keyword : keywords) {
            if (normalized.contains(keyword)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 递归处理单个值。
     * 这里的递归是为了处理日志数据结构,不是判断 Serializable 的自定义递归逻辑。
     */
    private Object sanitizeValue(Object value, String fieldName, int depth) {
        // 字段名命中敏感规则时,直接隐藏值,不继续读取内容。
        if (isSensitiveField(fieldName)) {
            return MASKED;
        }

        if (value == null) {
            return null;
        }

        if (depth >= MAX_DEPTH) {
            return DEPTH_LIMITED;
        }

        // 忽略 Servlet、文件、流、上传对象等不适合完整记录的对象。
        if (isInfrastructureObject(value)) {
            return "[已忽略对象:" + value.getClass().getSimpleName() + "]";
        }

        // 认证上下文通常包含用户、权限或令牌相关内容,默认不输出完整对象。
        if ("JwtUser".equals(value.getClass().getSimpleName())) {
            return "[认证上下文对象已隐藏]";
        }

        // 保留原项目 IgnoreLog 标记语义,又不将该项目自定义接口绑定到此示例包。
        // 正式项目可在这里改成 value instanceof IgnoreLog,并导入该接口的实际包名。
        if (implementsInterfaceNamed(value.getClass(), "IgnoreLog")) {
            return "[该对象标记为忽略日志]";
        }

        if (value instanceof String) {
            return sanitizeText((String) value);
        }

        if (value instanceof Character || value instanceof Number
                || value instanceof Boolean || value instanceof Enum
                || value instanceof UUID) {
            return value instanceof Enum ? ((Enum<?>) value).name() : value;
        }

        // 二进制内容绝不直接写入日志,只保留其长度。
        if (value instanceof byte[]) {
            return "[二进制内容已隐藏,长度=" + ((byte[]) value).length + "]";
        }

        // 集合先做摘要,不直接把整个集合交给 Jackson,避免常见的大列表日志膨胀。
        if (value instanceof Collection) {
            return sanitizeCollection((Collection<?>) value, depth);
        }

        if (value instanceof Map) {
            return sanitizeMap((Map<?, ?>) value, depth);
        }

        if (value.getClass().isArray()) {
            return sanitizeArray(value, depth);
        }

        try {
            // 对普通 DTO 使用 Jackson 转成 JSON 树;不要求 DTO 实现 Serializable。
            JsonNode jsonNode = objectMapper.valueToTree(value);
            return sanitizeJsonNode(jsonNode, null, depth + 1);
        } catch (RuntimeException ex) {
            // 转换失败时只返回类型信息,不回退到打印原始对象。
            return "[对象转换失败:" + value.getClass().getSimpleName() + "]";
        }
    }

    /** 为集合生成类型、总数和有限数量的样本,避免完整输出大量元素。 */
    private Map<String, Object> sanitizeCollection(Collection<?> collection, int depth) {
        Map<String, Object> result = new LinkedHashMap<String, Object>();
        result.put("type", collection.getClass().getSimpleName());
        result.put("size", collection.size());

        List<Object> preview = new ArrayList<Object>();
        Iterator<?> iterator = collection.iterator();
        int count = 0;
        while (iterator.hasNext() && count < MAX_COLLECTION_PREVIEW) {
            preview.add(sanitizeValue(iterator.next(), null, depth + 1));
            count++;
        }
        result.put("preview", preview);
        result.put("truncated", collection.size() > preview.size());
        return result;
    }

    /** 只处理 Map 中有限数量的键值,且按键名处理敏感字段。 */
    private Map<String, Object> sanitizeMap(Map<?, ?> source, int depth) {
        Map<String, Object> result = new LinkedHashMap<String, Object>();
        int count = 0;

        for (Map.Entry<?, ?> entry : source.entrySet()) {
            if (count >= MAX_MAP_ENTRIES) {
                result.put("_truncated", true);
                break;
            }

            String key = String.valueOf(entry.getKey());
            result.put(key, sanitizeValue(entry.getValue(), key, depth + 1));
            count++;
        }
        return result;
    }

    /** 只输出数组的有限数量样本。 */
    private Map<String, Object> sanitizeArray(Object array, int depth) {
        int length = Array.getLength(array);
        Map<String, Object> result = new LinkedHashMap<String, Object>();
        result.put("type", array.getClass().getComponentType().getSimpleName() + "[]");
        result.put("length", length);

        List<Object> preview = new ArrayList<Object>();
        int limit = Math.min(length, MAX_COLLECTION_PREVIEW);
        for (int i = 0; i < limit; i++) {
            preview.add(sanitizeValue(Array.get(array, i), null, depth + 1));
        }
        result.put("preview", preview);
        result.put("truncated", length > limit);
        return result;
    }

    /** 对 Jackson JSON 树执行递归脱敏和大小限制。 */
    private Object sanitizeJsonNode(JsonNode node, String fieldName, int depth) {
        if (isSensitiveField(fieldName)) {
            return MASKED;
        }
        if (node == null || node.isNull()) {
            return null;
        }
        if (depth >= MAX_DEPTH) {
            return DEPTH_LIMITED;
        }
        if (node.isTextual()) {
            return sanitizeText(node.asText());
        }
        if (node.isBoolean()) {
            return node.booleanValue();
        }
        if (node.isNumber()) {
            return node.numberValue();
        }

        if (node.isArray()) {
            List<Object> values = new ArrayList<Object>();
            int limit = Math.min(node.size(), MAX_COLLECTION_PREVIEW);
            for (int i = 0; i < limit; i++) {
                values.add(sanitizeJsonNode(node.get(i), null, depth + 1));
            }
            if (node.size() > limit) {
                Map<String, Object> summary = new LinkedHashMap<String, Object>();
                summary.put("size", node.size());
                summary.put("preview", values);
                summary.put("truncated", true);
                return summary;
            }
            return values;
        }

        if (node.isObject()) {
            Map<String, Object> values = new LinkedHashMap<String, Object>();
            Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
            int count = 0;
            while (fields.hasNext()) {
                Map.Entry<String, JsonNode> field = fields.next();
                if (count >= MAX_JSON_FIELDS) {
                    values.put("_truncated", true);
                    break;
                }
                values.put(field.getKey(), sanitizeJsonNode(
                        field.getValue(), field.getKey(), depth + 1));
                count++;
            }
            return values;
        }

        // 对二进制、POJO 等其他 JSON 节点类型不输出内部实现细节。
        return node.asText();
    }

    /** 截断超长字符串,并遮盖常见手机号的中间四位。 */
    private String sanitizeText(String text) {
        if (text == null) {
            return null;
        }

        // 先替换可能直接出现在 String 参数或响应中的 Bearer Token / JWT。
        String masked = BEARER_TOKEN_PATTERN.matcher(text).replaceAll("Bearer [已脱敏]");
        masked = JWT_PATTERN.matcher(masked).replaceAll("[JWT已脱敏]");

        Matcher matcher = PHONE_PATTERN.matcher(masked);
        masked = matcher.replaceAll("$1****$2");
        if (masked.length() > MAX_STRING_LENGTH) {
            return masked.substring(0, MAX_STRING_LENGTH) + "...[已截断]";
        }
        return masked;
    }

    /** 判断对象是否为不适合记录完整内容的文件、网络请求或流对象。 */
    private boolean isInfrastructureObject(Object value) {
        return value instanceof ServletRequest
                || value instanceof ServletResponse
                || value instanceof MultipartFile
                || value instanceof InputStream
                || value instanceof OutputStream
                || value instanceof Reader
                || value instanceof Writer
                || value instanceof File
                || value instanceof Path
                || value instanceof Resource
                || value instanceof Class;
    }

    /**
     * 递归检查对象是否实现了指定简单名称的标记接口。
     * 正式项目更推荐导入 IgnoreLog 后使用 instanceof 做编译期检查。
     */
    private boolean implementsInterfaceNamed(Class<?> type, String interfaceSimpleName) {
        if (type == null) {
            return false;
        }
        for (Class<?> interfaceType : type.getInterfaces()) {
            if (interfaceSimpleName.equals(interfaceType.getSimpleName())
                    || implementsInterfaceNamed(interfaceType, interfaceSimpleName)) {
                return true;
            }
        }
        return implementsInterfaceNamed(type.getSuperclass(), interfaceSimpleName);
    }
}

5.1 说明

上面代码流程为:

  1. 先识别明确不应记录的对象。
  2. 对集合、Map 和数组做有限摘要。
  3. 对普通对象使用 Jackson 转为 JSON 树。
  4. 对 JSON 树做字段脱敏和深度、字段数量限制。
  5. 转换失败时返回占位符,绝不回退到原始对象。

需要注意,ObjectMapper.valueToTree() 仍会先把普通 DTO 转换成树,再执行树上的深度限制。因此,如果某个 DTO 内嵌了极其庞大的集合,转换阶段仍可能消耗较多内存。对于特别大的对象,最佳做法是为日志创建专门的摘要 DTO,而不是把完整对象交给通用日志工具。

5.2 脱敏规则需要根据业务扩展

当前规则覆盖常见字段名,但不能自动识别所有企业内部的敏感字段。比如项目使用 certNo 表示证件号、acct 表示账号时,就需要补充规则。

另外,单独的手机号正则只是兜底,不等于身份证号、银行卡号、密钥和任意 Token 都能正确脱敏。对于密码、Token、授权头等字段,最可靠的处理是基于字段名或类型直接替换成固定占位符。

六、构建请求日志:ApiLogRequestBuilder

文件:ApiLogRequestBuilder.java

这个类负责从 ProceedingJoinPoint 中提取目标方法、注解、参数与当前 Servlet 请求信息。AOP 切面不需要再关心这些反射细节。

package com.myproject.demo.apilog.service;

import com.myproject.demo.annotations.ApiLog;
import com.myproject.demo.apilog.model.ApiRequestLog;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.aop.support.AopUtils;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;

/** 从 AOP 方法调用和当前 HTTP 请求中构建请求日志。 */
@Component
public class ApiLogRequestBuilder {

    private final ApiLogValueSanitizer valueSanitizer;

    public ApiLogRequestBuilder(ApiLogValueSanitizer valueSanitizer) {
        this.valueSanitizer = valueSanitizer;
    }

    /**
     * 构建请求日志。
     *
     * @param joinPoint 当前被 AOP 拦截的方法调用
     * @return 请求日志 DTO
     */
    public ApiRequestLog build(ProceedingJoinPoint joinPoint) {
        ApiRequestLog log = new ApiRequestLog();
        Method method = resolveTargetMethod(joinPoint);
        ApiLog apiLog = findApiLog(joinPoint, method);

        // 使用随机 ID,避免使用 AtomicLong 在多实例部署时发生 ID 冲突。
        log.setRequestId(UUID.randomUUID().toString().replace("-", ""));
        log.setTimestamp(System.currentTimeMillis());
        log.setApiName(resolveApiName(apiLog, method));
        log.setClassMethod(method.getDeclaringClass().getName() + "#" + method.getName());
        log.setParameters(buildParameters(joinPoint));

        // 非 HTTP 场景下允许构建普通方法日志,不因没有 Servlet 请求而失败。
        ServletRequestAttributes attributes = getServletRequestAttributes();
        if (attributes != null) {
            HttpServletRequest request = attributes.getRequest();
            log.setHttpMethod(request.getMethod());
            // getRequestURL 不包含 query string,默认避免将查询参数中的密钥写入日志。
            log.setRequestUrl(request.getRequestURL().toString());
            log.setRemoteAddr(request.getRemoteAddr());
        }
        return log;
    }

    /** 优先获取目标类上的具体实现方法,兼容接口方法与代理方法的差异。 */
    private Method resolveTargetMethod(ProceedingJoinPoint joinPoint) {
        Method signatureMethod = ((MethodSignature) joinPoint.getSignature()).getMethod();
        Object target = joinPoint.getTarget();
        if (target == null) {
            return signatureMethod;
        }
        return AopUtils.getMostSpecificMethod(signatureMethod, target.getClass());
    }

    /** 从目标方法或签名方法上读取 @ApiLog。 */
    private ApiLog findApiLog(ProceedingJoinPoint joinPoint, Method targetMethod) {
        ApiLog apiLog = AnnotatedElementUtils.findMergedAnnotation(targetMethod, ApiLog.class);
        if (apiLog != null) {
            return apiLog;
        }
        Method signatureMethod = ((MethodSignature) joinPoint.getSignature()).getMethod();
        return AnnotatedElementUtils.findMergedAnnotation(signatureMethod, ApiLog.class);
    }

    /** 解析可读接口名称,未配置注解描述时使用方法名兜底。 */
    private String resolveApiName(ApiLog apiLog, Method method) {
        if (apiLog != null && StringUtils.hasText(apiLog.value())) {
            return apiLog.value();
        }
        return method.getName();
    }

    /**
     * 以参数名为键构建参数 Map。
     * 如果编译产物没有保留参数名,使用 arg0、arg1 等可读性有限但稳定的占位名。
     */
    private Map<String, Object> buildParameters(ProceedingJoinPoint joinPoint) {
        Map<String, Object> parameters = new LinkedHashMap<String, Object>();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        String[] names = signature.getParameterNames();
        Object[] args = joinPoint.getArgs();

        for (int i = 0; i < args.length; i++) {
            String name = names != null && names.length == args.length
                    && StringUtils.hasText(names[i]) ? names[i] : "arg" + i;
            // null 也明确记录,便于区分“没有参数”和“参数值为空”。
            parameters.put(name, valueSanitizer.sanitize(args[i], name));
        }
        return parameters;
    }

    /** 安全获取当前 Servlet 请求属性;异步或非 Web 场景下可能为 null。 */
    private ServletRequestAttributes getServletRequestAttributes() {
        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
        return attributes instanceof ServletRequestAttributes
                ? (ServletRequestAttributes) attributes : null;
    }
}

6.1 参数名说明

MethodSignature.getParameterNames() 可能因为编译配置而无法获得真实参数名。可以在 Maven 的编译配置中启用 -parameters,例如:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <parameters>true</parameters>
        <source>1.8</source>
        <target>1.8</target>
    </configuration>
</plugin>

如果项目通过其他方式管理编译参数,请合并配置,不要重复定义多个互相冲突的 maven-compiler-plugin 配置。

这里不再只过滤参数名恰好等于 token 或 authorization 的值。所有参数统一经过 ApiLogValueSanitizer,这样嵌套 DTO 中的 password、accessToken 等字段也可以按字段名处理。

七、构建响应日志:ApiLogResponseBuilder

文件:ApiLogResponseBuilder.java

package com.myproject.demo.apilog.service;

import com.myproject.demo.apilog.model.ApiRequestLog;
import com.myproject.demo.apilog.model.ApiResponseLog;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.util.concurrent.CompletionStage;

/** 构建成功或异常情况下的响应日志。 */
@Component
public class ApiLogResponseBuilder {

    private static final String ASYNC_RESPONSE_NOTE =
            "[异步响应对象已返回;当前耗时不代表异步任务或流式传输的完整耗时]";

    private final ApiLogValueSanitizer valueSanitizer;

    public ApiLogResponseBuilder(ApiLogValueSanitizer valueSanitizer) {
        this.valueSanitizer = valueSanitizer;
    }

    /**
     * 创建响应日志。
     *
     * @param requestLog 请求日志,用于复用关联 ID 和接口信息
     * @param response   业务方法的返回对象,异常时可能为 null
     * @param durationMs Java 方法执行耗时
     * @param error      业务方法抛出的异常;正常返回时为 null
     */
    public ApiResponseLog build(ApiRequestLog requestLog,
                                Object response,
                                long durationMs,
                                Throwable error) {
        ApiResponseLog log = new ApiResponseLog();
        log.setRequestId(requestLog.getRequestId());
        log.setApiName(requestLog.getApiName());
        log.setClassMethod(requestLog.getClassMethod());
        log.setDurationMs(durationMs);
        log.setTimestamp(System.currentTimeMillis());
        log.setMethodSuccess(error == null);

        if (error != null) {
            // 默认只记录异常类型,避免异常消息包含 SQL 参数、Token 等敏感信息。
            log.setErrorType(error.getClass().getName());
            log.setResponse("[业务方法执行异常]");
            return log;
        }

        Object body = response;
        if (response instanceof ResponseEntity) {
            ResponseEntity<?> responseEntity = (ResponseEntity<?>) response;
            log.setHttpStatus(responseEntity.getStatusCodeValue());
            body = responseEntity.getBody();
        }

        if (isAsyncResponse(body)) {
            log.setResponse(ASYNC_RESPONSE_NOTE);
        } else {
            log.setResponse(valueSanitizer.sanitize(body, "response"));
        }
        return log;
    }

    /**
     * 识别部分常见异步返回值。
     * 这只能避免误解当前耗时,不能代替异步任务真正完成时的回调日志。
     */
    private boolean isAsyncResponse(Object value) {
        if (value == null) {
            return false;
        }
        if (value instanceof SseEmitter || value instanceof CompletionStage) {
            return true;
        }
        // 不直接依赖 Reactor 类型,避免没有引入 WebFlux/Reactor 的模块无法编译。
        return implementsInterfaceNamed(value.getClass(), "Publisher");
    }

    /** 递归检查类型层级中的接口名,可识别 org.reactivestreams.Publisher。 */
    private boolean implementsInterfaceNamed(Class<?> type, String interfaceSimpleName) {
        if (type == null) {
            return false;
        }
        for (Class<?> interfaceType : type.getInterfaces()) {
            if (interfaceSimpleName.equals(interfaceType.getSimpleName())
                    || implementsInterfaceNamed(interfaceType, interfaceSimpleName)) {
                return true;
            }
        }
        return implementsInterfaceNamed(type.getSuperclass(), interfaceSimpleName);
    }
}

这里对 ResponseEntity 提取 HTTP 状态码与 body,而不是直接把整个 ResponseEntity 对象输出。对于普通对象,统一经过安全处理器。

7.1 异步方法的耗时边界

例如 Controller 返回 SseEmitter 或 Flux<String>,Java 方法可能只花几十毫秒就返回一个异步对象,而流内容可能持续传输几十秒。当前切面记录的是方法调用耗时,不是流式响应完整耗时。

如果业务需要统计真实流式耗时,应当在 SseEmitter 的 onCompletion、onTimeout、onError 回调,或者 Reactor 流的 doFinally 等位置另行记录完成时间。不同异步模型应采用各自合适的生命周期机制,不能只依赖同步 AOP 的 finally。

八、统一日志输出:ApiLogWriter

文件:ApiLogWriter.java

package com.myproject.demo.apilog.service;

import com.myproject.demo.apilog.model.ApiRequestLog;
import com.myproject.demo.apilog.model.ApiResponseLog;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

/** 负责把日志 DTO 输出到应用日志,不处理 AOP、参数提取或脱敏规则。 */
@Component
@Slf4j
public class ApiLogWriter {

    private final ObjectMapper objectMapper;

    public ApiLogWriter(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    /** 输出请求日志。参数应已在 builder 阶段完成安全处理。 */
    public void writeRequest(ApiRequestLog requestLog) {
        try {
            // 使用占位符传递 JSON 字符串,避免 JSON 内的大括号被误当成格式参数。
            log.info("API_REQUEST {}", objectMapper.writeValueAsString(requestLog));
        } catch (JsonProcessingException ex) {
            // 兜底日志只包含少量元数据,不回退打印原始请求对象或原始参数。
            log.warn("API_REQUEST 日志序列化失败,requestId={}, apiName={}",
                    requestLog.getRequestId(), requestLog.getApiName());
        }
    }

    /** 输出响应日志。若日志序列化失败,不向业务调用方抛出异常。 */
    public void writeResponse(ApiResponseLog responseLog) {
        try {
            log.info("API_RESPONSE {}", objectMapper.writeValueAsString(responseLog));
        } catch (JsonProcessingException ex) {
            log.warn("API_RESPONSE 日志序列化失败,requestId={}, apiName={}",
                    responseLog.getRequestId(), responseLog.getApiName());
        }
    }
}

ObjectMapper 通常由 Spring Boot 自动配置。如果项目没有自动配置 Jackson,需要自行提供 ObjectMapper Bean。

另外,不建议在序列化失败后使用 log.info("{}", 原始对象) 作为兜底,因为这相当于绕过了 JSON 脱敏流程。

九、抽象监控上报接口

在模块内部定义一个接口,再在业务工程中适配监控服务。

文件:ApiLogMonitor.java

package com.myproject.demo.apilog.service;

import javax.servlet.http.HttpServletRequest;

/**
 * API 监控上报抽象。
 * 日志切面只依赖这个接口,不需要了解监控数据写入数据库或其他系统的细节。
 */
public interface ApiLogMonitor {

    /**
     * 上报一次接口调用的耗时。
     *
     * @param request    当前 HTTP 请求;非 HTTP 场景下可能没有请求对象
     * @param durationMs 方法执行耗时,单位毫秒
     */
    void report(HttpServletRequest request, long durationMs);
}

在项目适配层中,调用监控服务 monitorLogService.monitorLog 把请求的日志落库或者其他操作:


import com.myproject.demo.apilog.service.ApiLogMonitor;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
// import 项目中 MonitorLogService 的真实包路径;

/**
 * 适配器示例:把日志模块的统一监控接口转发给项目原有 MonitorLogService。
 *
 * <p>请在项目中导入 MonitorLogService 的真实包路径,并按实际方法签名调整。</p>
 */
@Component
public class ExistingMonitorLogAdapter implements ApiLogMonitor {

    private final MonitorLogService monitorLogService;

    public ExistingMonitorLogAdapter(MonitorLogService monitorLogService) {
        this.monitorLogService = monitorLogService;
    }

    @Override
    public void report(HttpServletRequest request, long durationMs) {
        if (request == null) {
            return;
        }
        // 调用监控服务 把请求的日志插入数据库或者其他操作,这里省略 monitorLog 
        monitorLogService.monitorLog(request, null, durationMs);
    }
}

若暂时不需要数据库监控,可以先不注册 ApiLogMonitor 实现;下面的切面通过 ObjectProvider 获取可选 Bean,不会因为监控适配器缺失而无法启动。

十、 AOP 切面

文件:ApilLogHandlerAdvice.java

切面类现在只做协调工作:生成请求日志、开始计时、执行业务方法、记录成功或异常结束日志、尝试上报监控。

package com.myproject.demo.apilog;

import com.myproject.demo.apilog.model.ApiRequestLog;
import com.myproject.demo.apilog.model.ApiResponseLog;
import com.myproject.demo.apilog.service.ApiLogMonitor;
import com.myproject.demo.apilog.service.ApiLogRequestBuilder;
import com.myproject.demo.apilog.service.ApiLogResponseBuilder;
import com.myproject.demo.apilog.service.ApiLogWriter;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.concurrent.TimeUnit;

/**
 * API 日志 AOP 切面。
 *
 * <p>只负责组织各个组件的调用,不在这里编写参数反射、脱敏、JSON 转换等细节。</p>
 */
@Aspect
@Component
@Slf4j
//设置本切面优先级为靠后的优先级
@Order(Ordered.LOWEST_PRECEDENCE - 5)
public class ApilLogHandlerAdvice {

    private final ApiLogRequestBuilder requestBuilder;
    private final ApiLogResponseBuilder responseBuilder;
    private final ApiLogWriter logWriter;
    private final ObjectProvider<ApiLogMonitor> monitorProvider;

    public ApilLogHandlerAdvice(ApiLogRequestBuilder requestBuilder,
                                        ApiLogResponseBuilder responseBuilder,
                                        ApiLogWriter logWriter,
                                        ObjectProvider<ApiLogMonitor> monitorProvider) {
        this.requestBuilder = requestBuilder;
        this.responseBuilder = responseBuilder;
        this.logWriter = logWriter;
        this.monitorProvider = monitorProvider;
    }

    /**
     * 拦截标注 @ApiLog 的方法。
     * 无论正常返回还是抛出异常,finally 都会尝试输出结束日志和上报耗时。
     */
    @Around("@annotation(com.myproject.demo.annotations.ApiLog)")
    public Object aroundApi(ProceedingJoinPoint joinPoint) throws Throwable {
        ApiRequestLog requestLog = null;

        // 日志初始化失败不应该阻止业务方法执行,因此将其与业务调用隔离。
        try {
            requestLog = requestBuilder.build(joinPoint);
            logWriter.writeRequest(requestLog);
        } catch (Exception logException) {
            log.warn("API 请求日志初始化失败,继续执行业务方法", logException);
        }

        long startNanos = System.nanoTime();
        Object result = null;
        Throwable businessError = null;

        try {
            // 只有这一行执行真正的目标业务方法。
            result = joinPoint.proceed();
            return result;
        } catch (Throwable ex) {
            // 保存异常供 finally 构造失败日志,然后原样抛出,不能吞掉业务异常。
            businessError = ex;
            throw ex;
        } finally {
            long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);

            // 结束日志失败不能覆盖业务返回结果,也不能覆盖原始业务异常。
            if (requestLog != null) {
                try {
                    ApiResponseLog responseLog = responseBuilder.build(
                            requestLog, result, durationMs, businessError);
                    logWriter.writeResponse(responseLog);
                } catch (Exception logException) {
                    log.warn("API 响应日志处理失败,requestId={}", requestLog.getRequestId(), logException);
                }
            }

            // 监控组件是可选依赖;监控失败不能改变业务调用结果。
            reportMonitor(durationMs);
        }
    }

    /** 安全获取当前请求并调用可选监控组件。 */
    private void reportMonitor(long durationMs) {
        try {
            ApiLogMonitor monitor = monitorProvider.getIfAvailable();
            if (monitor == null) {
                return;
            }

            RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
            HttpServletRequest request = attributes instanceof ServletRequestAttributes
                    ? ((ServletRequestAttributes) attributes).getRequest() : null;

            monitor.report(request, durationMs);
        } catch (Exception monitorException) {
            // 监控数据库或外部系统出现故障,不应影响已经完成的业务处理。
            log.warn("API 监控上报失败", monitorException);
        }
    }
}

10.1 为什么 joinPoint.proceed() 必须重新抛出异常?

切面不应该擅自改变业务方法原本的异常语义。这里捕获异常只是为了在 finally 中记录失败结果,随后通过 throw ex 将同一个异常继续交给 Spring MVC 的异常处理流程。

不要这样写:

catch (Exception ex) {
    log.error("接口异常", ex);
    return null; // 错误:吞掉异常并改变接口行为
}

这种写法可能让调用方拿到一个空响应,而统一异常处理器也无法按原计划处理异常。

10.2 为什么日志输出也要做异常保护?

日志属于辅助能力,不应轻易改变业务接口的返回结果。上面的代码把请求日志构建、响应日志构建和监控上报分别隔离,某一环节失败不会直接阻止 joinPoint.proceed(),也不会让监控异常覆盖业务异常。

需要说明的是,catch (Exception) 不是为了吞掉所有 JVM 级错误,例如 OutOfMemoryError。通常不应在通用日志切面中尝试捕获并恢复所有 Error。

十一、Controller 中如何使用

有了上述组件后,在 Controller 中相关接口上使用 @ApiLog("接口描述") 注解便可开启接口的入参、响应日志打印。

如果省略接口描述,直接使用@ApiLog会默认使用方法名作为接口描述。

注意:如果某个普通 Service 方法也标注了 @ApiLog,并且该方法调用经过 Spring AOP 代理,它也可能被拦截并打印日志。

因此,这个注解和切点本身并没有限制只能记录 Controller 层方法。

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    /** 查询订单接口:请求参数与返回摘要由 @ApiLog 切面统一记录。 */
    @ApiLog("查询用车订单")
    @GetMapping("/{orderNo}")
    public OrderVO getOrder(@PathVariable String orderNo) {
        return orderService.getOrder(orderNo);
    }

    /** 创建订单接口:即使业务方法抛出异常,切面也会记录失败结束日志。 */
    @ApiLog("创建用车订单")
    @PostMapping
    public OrderVO createOrder(@RequestBody CreateOrderRequest request) {
        return orderService.createOrder(request);
    }
}

请求日志和响应日志将使用相同的 requestId。日志平台可以通过 requestId 检索同一次调用的两条记录。

请求日志示例:

{
  "requestId": "2a0d3f0abf3546b8a60f94164a791d5a",
  "apiName": "创建用车订单",
  "classMethod": "com.example.OrderController#createOrder",
  "httpMethod": "POST",
  "requestUrl": "http://localhost:8080/orders",
  "remoteAddr": "127.0.0.1",
  "parameters": {
    "request": {
      "mobile": "[已脱敏]",
      "password": "[已脱敏]",
      "orderType": "BUSINESS"
    }
  },
  "timestamp": 1793100000000
}

响应日志示例:

{
  "requestId": "2a0d3f0abf3546b8a60f94164a791d5a",
  "apiName": "创建用车订单",
  "classMethod": "com.example.OrderController#createOrder",
  "durationMs": 36,
  "methodSuccess": true,
  "httpStatus": null,
  "response": {
    "orderNo": "ORD202610270001",
    "status": "CREATED"
  },
  "errorType": null,
  "timestamp": 1793100000036
}

以上内容仅为日志结构示例,具体字段以项目 DTO 和 Jackson 配置为准。

十二、需要特别注意的生产实践

12.1 不要把完整请求头、Cookie、Token 全部写入日志

即使排查问题需要身份信息,也应优先记录经过处理的用户标识或 TraceId,而不是完整 Authorization、Cookie 或 JWT。请求头中可能包含凭证,完整输出会形成额外的数据泄露面。

12.2 日志脱敏应采用失败关闭原则

脱敏或 JSON 转换失败时,只记录占位符和必要的对象类型信息。绝不能在失败后回退到打印原始对象。

12.3 日志长度控制不等于所有场景都不会消耗大量内存

示例代码对集合、Map、数组与 JSON 树增加了限制,但普通 DTO 仍可能先经 Jackson 转换成树,再应用节点限制。对于有大型嵌套对象、文件内容或大量明细的接口,建议给日志定义专用摘要 DTO,并优先记录数量、状态、关键业务 ID,而不是完整响应。

12.4 不要无条件记录每个接口的完整响应

登录、支付、财务、用车人信息、身份证件、发票、银行卡等接口,最好默认采用白名单方式决定记录哪些字段。通用日志组件应该优先记录有助于排查问题的必要信息,而不是复制一份完整的业务数据。

12.5 remoteAddr 不是永远等于真实客户端 IP

如果请求经过负载均衡器或反向代理,getRemoteAddr() 可能获得代理节点地址。如果需要获取真实客户端 IP,应在可信代理配置下处理转发头,例如 X-Forwarded-For;不要无条件相信外部客户端自行提交的转发头,否则可能被伪造。

12.6 @Order 的优先级需要与项目其他切面一起验证

@Order(Ordered.LOWEST_PRECEDENCE - 5) 表示该切面在已排序的 Spring AOP 通知中设置了一个靠后的优先级,但实际执行顺序依然取决于项目中事务、鉴权、审计等其他切面的配置。若日志必须覆盖某个切面的执行耗时,需要结合实际代理链做集成测试。

12.7 如果需要关联分布式链路,应优先考虑 TraceId / MDC

这里使用 UUID 作为请求和响应日志的关联 ID,解决单次调用的日志关联问题。如果项目已经接入 Micrometer Tracing、SkyWalking、Zipkin 或其他链路追踪系统,建议复用已有 TraceId,并通过 MDC 贯穿同一线程中的其他日志。异步切换线程时还应确认上下文传播方式。

十三、推荐的测试清单

测试时至少应覆盖以下场景:

场景期望结果
普通 DTO 请求与响应请求和响应都有日志,关联 ID 相同
参数包含 password、token、mobile对应字段被脱敏
DTO 中嵌套 Map 或集合敏感字段仍被脱敏,集合只输出有限样本
参数为 MultipartFile、Servlet 请求、输入流不输出完整对象内容
业务方法抛出运行时异常输出失败结束日志,并继续抛出原异常
请求日志构建失败业务方法仍然执行
响应 JSON 转换失败使用占位符或安全兜底,不输出原始对象
监控服务抛出异常不改变业务接口的返回结果或原始异常
响应为 ResponseEntity记录状态码和经过处理的 body
响应为 List / PageInfo只记录有限摘要,避免输出全部分页数据
响应为 SseEmitter / Flux明确标记当前耗时并非流式处理完整耗时
没有 HTTP 请求上下文不因 RequestContextHolder 为空而报错

建议使用单元测试验证 ApiLogValueSanitizer,再使用 Spring Boot 集成测试验证 AOP 确实拦截目标方法。单测脱敏成功不代表 AOP 一定生效,二者应分别验证。

十四、总结

  • ApilLogHandlerAdvice 负责 AOP 流程、计时与异常路径。
  • ApiLogRequestBuilder 负责构建请求日志。
  • ApiLogResponseBuilder 负责构建成功或异常的响应日志。
  • ApiLogValueSanitizer 负责敏感字段脱敏、对象过滤与日志大小控制。
  • ApiLogWriter 负责 JSON 序列化和安全输出。
  • ApiRequestLog、ApiResponseLog 只承载日志数据。
  • ApiLogMonitor 隔离项目现有的监控实现。

在生产环境中,API 日志应坚持三个原则:日志不能影响业务;脱敏失败不能泄露原始数据;日志内容只记录排查问题所必需的信息。 做到这些,统一日志切面才不仅是一个方便的工具,也是一项相对可靠的可观测性基础能力。

深度学习工具包Deprecation notice.-----This toolbox is outdated and no longer maintained.There are much better tools available for deep learning than this toolbox, e.g. [Theano](http://deeplearning.net/software/theano/), [torch](http://torch.ch/) or [tensorflow](http://www.tensorflow.org/)I would suggest you use one of the tools mentioned above rather than use this toolbox.Best, Rasmus.DeepLearnToolbox================A Matlab toolbox for Deep Learning.Deep Learning is a new subfield of machine learning that focuses on learning deep hierarchical models of data.It is inspired by the human brain's apparent deep (layered, hierarchical) architecture.A good overview of the theory of Deep Learning theory is[Learning Deep Architectures for AI](http://www.iro.umontreal.ca/~bengioy/papers/ftml_book.pdf)For a more informal introduction, see the following videos by Geoffrey Hinton and Andrew Ng.* [The Next Generation of Neural Networks](http://www.youtube.com/watch?v=AyzOUbkUf3M) (Hinton, 2007)* [Recent Developments in Deep Learning](http://www.youtube.com/watch?v=VdIURAu1-aU) (Hinton, 2010)* [Unsupervised Feature Learning and Deep Learning](http://www.youtube.com/watch?v=ZmNOAtZIgIk) (Ng, 2011)If you use this toolbox in your research please cite [Prediction as a candidate for learning deep hierarchical models of data](http://www2.imm.dtu.dk/pubdb/views/publication_details.php?id=6284)```@MASTERSTHESIS\{IMM2012-06284, author = "R. B. Palm", title = "Prediction as a candidate for learning deep hierarchical models of data", year = "2012",}```Contact: rasmusbergpalm at gmail dot comDirectories included in the toolbox-----------------------------------`NN/` - A library for Feedforward Backpropagation Neural Networks`CNN/` - A library for Convolutional Neural Networks`DBN/` - A library for Deep Belief Networks`SAE/` - A library for Stacked Auto-Encoders`CAE/` - A library for Convolutional Auto-Encoders`util/` - Utility functions used by the libraries`data/` - Data used by the examples`tests/` - unit tests to verify toolbox is workingFor references on each library check REFS.mdSetup-----1. Download.2. addpath(genpath('DeepLearnToolbox'));Example: Deep Belief Network---------------------```matlabfunction test_example_DBNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit RBM and visualize its weightsrand('state',0)dbn.sizes = [100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);figure; visualize(dbn.rbm{1}.W'); % Visualize the RBM weights%% ex2 train a 100-100 hidden unit DBN and use its weights to initialize a NNrand('state',0)%train dbndbn.sizes = [100 100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);%unfold dbn to nnnn = dbnunfoldtonn(dbn, 10);nn.activation_function = 'sigm';%train nnopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.10, 'Too big error');```Example: Stacked Auto-Encoders---------------------```matlabfunction test_example_SAEload mnist_uint8;train_x = double(train_x)/255;test_x = double(test_x)/255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit SDAE and use it to initialize a FFNN% Setup and train a stacked denoising autoencoder (SDAE)rand('state',0)sae = saesetup([784 100]);sae.ae{1}.activation_function = 'sigm';sae.ae{1}.learningRate = 1;sae.ae{1}.inputZeroMaskedFraction = 0.5;opts.numepochs = 1;opts.batchsize = 100;sae = saetrain(sae, train_x, opts);visualize(sae.ae{1}.W{1}(:,2:end)')% Use the SDAE to initialize a FFNNnn = nnsetup([784 100 10]);nn.activation_function = 'sigm';nn.learningRate = 1;nn.W{1} = sae.ae{1}.W{1};% Train the FFNNopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.16, 'Too big error');```Example: Convolutional Neural Nets---------------------```matlabfunction test_example_CNNload mnist_uint8;train_x = double(reshape(train_x',28,28,60000))/255;test_x = double(reshape(test_x',28,28,10000))/255;train_y = double(train_y');test_y = double(test_y');%% ex1 Train a 6c-2s-12c-2s Convolutional neural network %will run 1 epoch in about 200 second and get around 11% error. %With 100 epochs you'll get around 1.2% errorrand('state',0)cnn.layers = { struct('type', 'i') %input layer struct('type', 'c', 'outputmaps', 6, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %sub sampling layer struct('type', 'c', 'outputmaps', 12, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %subsampling layer};cnn = cnnsetup(cnn, train_x, train_y);opts.alpha = 1;opts.batchsize = 50;opts.numepochs = 1;cnn = cnntrain(cnn, train_x, train_y, opts);[er, bad] = cnntest(cnn, test_x, test_y);%plot mean squared errorfigure; plot(cnn.rL);assert(er<0.12, 'Too big error');```Example: Neural Networks---------------------```matlabfunction test_example_NNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);% normalize[train_x, mu, sigma] = zscore(train_x);test_x = normalize(test_x, mu, sigma);%% ex1 vanilla neural netrand('state',0)nn = nnsetup([784 100 10]);opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samples[nn, L] = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.08, 'Too big error');%% ex2 neural net with L2 weight decayrand('state',0)nn = nnsetup([784 100 10]);nn.weightPenaltyL2 = 1e-4; % L2 weight decayopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex3 neural net with dropoutrand('state',0)nn = nnsetup([784 100 10]);nn.dropoutFraction = 0.5; % Dropout fraction opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex4 neural net with sigmoid activation functionrand('state',0)nn = nnsetup([784 100 10]);nn.activation_function = 'sigm'; % Sigmoid activation functionnn.learningRate = 1; % Sigm require a lower learning rateopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex5 plotting functionalityrand('state',0)nn = nnsetup([784 20 10]);opts.numepochs = 5; % Number of full sweeps through datann.output = 'softmax'; % use softmax outputopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex6 neural net with sigmoid activation and plotting of validation and training error% split training data into training and validation datavx = train_x(1:10000,:);tx = train_x(10001:end,:);vy = train_y(1:10000,:);ty = train_y(10001:end,:);rand('state',0)nn = nnsetup([784 20 10]); nn.output = 'softmax'; % use softmax outputopts.numepochs = 5; % Number of full sweeps through dataopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, tx, ty, opts, vx, vy); % nntrain takes validation set as last two arguments (optionally)[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');```[![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/rasmusbergpalm/deeplearntoolbox/trend.png)](https://bitdeli.com/free "Bitdeli Badge")
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值