feat(core): 新增请求响应可重复读流处理并优化日志模块

增加访问日志打印处理:包括参数打印、过滤敏感参数和超长参数配置
This commit is contained in:
liquor
2025-03-25 13:09:06 +00:00
committed by Charles7c
parent 1903520433
commit da5e162a2a
20 changed files with 811 additions and 101 deletions

View File

@@ -23,6 +23,12 @@
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
<!-- servlet包 -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</dependency>
<!-- Hibernate Validator -->
<dependency>
<groupId>org.hibernate.validator</groupId>

View File

@@ -0,0 +1,101 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.core.wrapper;
import cn.hutool.core.io.IoUtil;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* 可重复读取请求体的包装器
* 支持文件流直接透传,非文件流可重复读取
*
* @author echo
* @since 2025/03/25 11:11
**/
public class RepeatReadRequestWrapper extends HttpServletRequestWrapper {
private byte[] cachedBody;
private final HttpServletRequest originalRequest;
public RepeatReadRequestWrapper(HttpServletRequest request) throws IOException {
super(request);
this.originalRequest = request;
// 判断是否为文件上传请求
if (!isMultipartContent(request)) {
this.cachedBody = IoUtil.readBytes(request.getInputStream(), false);
}
}
/**
* 检查是否为文件上传请求
*/
private boolean isMultipartContent(HttpServletRequest request) {
return request.getContentType() != null && request.getContentType().toLowerCase().startsWith("multipart/");
}
@Override
public ServletInputStream getInputStream() throws IOException {
// 如果是文件上传,直接返回原始输入流
if (isMultipartContent(originalRequest)) {
return originalRequest.getInputStream();
}
// 非文件上传,返回可重复读取的输入流
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cachedBody);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return byteArrayInputStream.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
// 非阻塞I/O这里可以根据需要实现
}
@Override
public int read() {
return byteArrayInputStream.read();
}
};
}
@Override
public BufferedReader getReader() throws IOException {
// 如果是文件上传直接返回原始Reader
if (isMultipartContent(originalRequest)) {
return originalRequest.getReader();
}
return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(cachedBody), StandardCharsets.UTF_8));
}
}

View File

@@ -0,0 +1,128 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.core.wrapper;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
/**
* 可重复读取响应内容的包装器
* 支持缓存响应内容,便于日志记录和后续处理 (不缓存SSE)
*
* @author echo
* @since 2025/03/25 11:11
**/
public class RepeatReadResponseWrapper extends HttpServletResponseWrapper {
private final ByteArrayOutputStream cachedOutputStream = new ByteArrayOutputStream();
private final PrintWriter writer = new PrintWriter(cachedOutputStream, true);
// 是否为流式响应
private boolean isStreamingResponse = false;
public RepeatReadResponseWrapper(HttpServletResponse response) {
super(response);
checkStreamingResponse();
}
@Override
public void setContentType(String type) {
super.setContentType(type);
// 根据 Content-Type 判断是否为流式响应
if (type != null) {
String lowerType = type.toLowerCase();
isStreamingResponse = lowerType.contains("text/event-stream");
}
}
private void checkStreamingResponse() {
String contentType = getContentType();
if (contentType != null) {
String lowerType = contentType.toLowerCase();
isStreamingResponse = lowerType.contains("text/event-stream");
}
}
@Override
public ServletOutputStream getOutputStream() throws IOException {
checkStreamingResponse();
if (isStreamingResponse) {
// 对于 SSE 流式响应,直接返回原始响应流,不做额外处理
return super.getOutputStream();
}
return new ServletOutputStream() {
@Override
public boolean isReady() {
return true;
}
@Override
public void setWriteListener(WriteListener writeListener) {
}
@Override
public void write(int b) throws IOException {
cachedOutputStream.write(b);
}
@Override
public void write(byte[] b) throws IOException {
cachedOutputStream.write(b);
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
cachedOutputStream.write(b, off, len);
}
};
}
@Override
public PrintWriter getWriter() throws IOException {
checkStreamingResponse();
if (isStreamingResponse) {
// 对于 SSE 流式响应,直接返回原始响应写入器,不做额外处理
return super.getWriter();
}
return writer;
}
public String getResponseContent() {
if (!isStreamingResponse) {
writer.flush();
return cachedOutputStream.toString(StandardCharsets.UTF_8);
}
return null;
}
public void copyBodyToResponse() throws IOException {
if (!isStreamingResponse && cachedOutputStream.size() > 0) {
getResponse().getOutputStream().write(cachedOutputStream.toByteArray());
}
}
public boolean isStreamingResponse() {
return isStreamingResponse;
}
}

View File

@@ -26,9 +26,12 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import top.continew.starter.log.handler.LogHandler;
import top.continew.starter.log.http.servlet.RecordableServletHttpRequest;
import top.continew.starter.log.http.servlet.RecordableServletHttpResponse;
import top.continew.starter.log.model.AccessLogContext;
import top.continew.starter.log.model.LogProperties;
import java.time.Duration;
import java.time.Instant;
/**
@@ -43,9 +46,11 @@ public class AccessLogAspect {
private static final Logger log = LoggerFactory.getLogger(AccessLogAspect.class);
private final LogProperties logProperties;
private final LogHandler logHandler;
public AccessLogAspect(LogProperties logProperties) {
public AccessLogAspect(LogProperties logProperties, LogHandler logHandler) {
this.logProperties = logProperties;
this.logHandler = logHandler;
}
/**
@@ -108,19 +113,18 @@ public class AccessLogAspect {
HttpServletRequest request = attributes.getRequest();
HttpServletResponse response = attributes.getResponse();
try {
// 打印请求日志
if (Boolean.TRUE.equals(logProperties.getIsPrint())) {
log.info("[{}] {}", request.getMethod(), request.getRequestURI());
}
logHandler.processAccessLogStartReq(AccessLogContext.builder()
.startTime(startTime)
.request(new RecordableServletHttpRequest(request))
.properties(logProperties)
.build());
return joinPoint.proceed();
} finally {
Instant endTime = Instant.now();
if (Boolean.TRUE.equals(logProperties.getIsPrint())) {
Duration timeTaken = Duration.between(startTime, endTime);
log.info("[{}] {} {} {}ms", request.getMethod(), request.getRequestURI(), response != null
? response.getStatus()
: "N/A", timeTaken.toMillis());
}
logHandler.processAccessLogEndReq(AccessLogContext.builder()
.endTime(endTime)
.response(new RecordableServletHttpResponse(response, response.getStatus()))
.build());
}
}
}

View File

@@ -32,7 +32,7 @@ import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import top.continew.starter.log.annotation.Log;
import top.continew.starter.log.dao.LogDao;
import top.continew.starter.log.LogHandler;
import top.continew.starter.log.handler.LogHandler;
import top.continew.starter.log.model.LogProperties;
import top.continew.starter.log.model.LogRecord;
import top.continew.starter.web.util.SpringWebUtils;

View File

@@ -29,9 +29,9 @@ import top.continew.starter.log.aspect.AccessLogAspect;
import top.continew.starter.log.aspect.LogAspect;
import top.continew.starter.log.dao.LogDao;
import top.continew.starter.log.dao.impl.DefaultLogDaoImpl;
import top.continew.starter.log.filter.LogFilter;
import top.continew.starter.log.handler.AopLogHandler;
import top.continew.starter.log.LogFilter;
import top.continew.starter.log.LogHandler;
import top.continew.starter.log.handler.LogHandler;
import top.continew.starter.log.model.LogProperties;
/**
@@ -49,9 +49,11 @@ public class LogAutoConfiguration {
private static final Logger log = LoggerFactory.getLogger(LogAutoConfiguration.class);
private final LogProperties logProperties;
private final LogHandler logHandler;
public LogAutoConfiguration(LogProperties logProperties) {
public LogAutoConfiguration(LogProperties logProperties, LogHandler logHandler) {
this.logProperties = logProperties;
this.logHandler = logHandler;
}
/**
@@ -66,13 +68,12 @@ public class LogAutoConfiguration {
/**
* 日志切面
*
* @param logHandler 日志处理器
* @param logDao 日志持久层接口
* @return {@link LogAspect }
*/
@Bean
@ConditionalOnMissingBean
public LogAspect logAspect(LogHandler logHandler, LogDao logDao) {
public LogAspect logAspect(LogDao logDao) {
return new LogAspect(logProperties, logHandler, logDao);
}
@@ -84,7 +85,7 @@ public class LogAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public AccessLogAspect accessLogAspect() {
return new AccessLogAspect(logProperties);
return new AccessLogAspect(logProperties, logHandler);
}
/**

View File

@@ -16,8 +16,6 @@
package top.continew.starter.log.handler;
import top.continew.starter.log.AbstractLogHandler;
/**
* 日志处理器-AOP 版实现
*

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package top.continew.starter.log;
package top.continew.starter.log.filter;
import cn.hutool.extra.spring.SpringUtil;
import jakarta.servlet.FilterChain;
@@ -25,15 +25,13 @@ import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.core.Ordered;
import org.springframework.lang.NonNull;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import org.springframework.web.util.WebUtils;
import top.continew.starter.core.wrapper.RepeatReadRequestWrapper;
import top.continew.starter.core.wrapper.RepeatReadResponseWrapper;
import top.continew.starter.log.model.LogProperties;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Objects;
/**
* 日志过滤器
@@ -67,20 +65,24 @@ public class LogFilter extends OncePerRequestFilter implements Ordered {
filterChain.doFilter(request, response);
return;
}
boolean isMatch = logProperties.isMatch(request.getRequestURI());
// 包装输入流可重复读取
if (!isMatch && this.isRequestWrapper(request)) {
request = new ContentCachingRequestWrapper(request);
}
// 包装输出流可重复读取
boolean isResponseWrapper = !isMatch && this.isResponseWrapper(response);
if (isResponseWrapper) {
response = new ContentCachingResponseWrapper(response);
}
filterChain.doFilter(request, response);
// 更新响应不操作这一步会导致接口响应空白
if (isResponseWrapper) {
this.updateResponse(response);
// 处理可重复读取的请求
HttpServletRequest requestWrapper = (isMatch || !this.isRequestWrapper(request))
? request
: new RepeatReadRequestWrapper(request);
// 处理可重复读取的响应
HttpServletResponse responseWrapper = (isMatch || !this.isResponseWrapper(response))
? response
: new RepeatReadResponseWrapper(response);
filterChain.doFilter(requestWrapper, responseWrapper);
// 如果响应被包装了复制缓存数据到原始响应
if (responseWrapper instanceof RepeatReadResponseWrapper wrappedResponse) {
wrappedResponse.copyBodyToResponse();
}
}
@@ -121,7 +123,7 @@ public class LogFilter extends OncePerRequestFilter implements Ordered {
* @return truefalse
*/
private boolean isRequestWrapper(HttpServletRequest request) {
return !(request instanceof ContentCachingRequestWrapper);
return !(request instanceof RepeatReadRequestWrapper);
}
/**
@@ -131,18 +133,6 @@ public class LogFilter extends OncePerRequestFilter implements Ordered {
* @return truefalse
*/
private boolean isResponseWrapper(HttpServletResponse response) {
return !(response instanceof ContentCachingResponseWrapper);
}
/**
* 更新响应
*
* @param response 响应对象
* @throws IOException /
*/
private void updateResponse(HttpServletResponse response) throws IOException {
ContentCachingResponseWrapper responseWrapper = WebUtils
.getNativeResponse(response, ContentCachingResponseWrapper.class);
Objects.requireNonNull(responseWrapper).copyBodyToResponse();
return !(response instanceof RepeatReadResponseWrapper);
}
}

View File

@@ -14,21 +14,31 @@
* limitations under the License.
*/
package top.continew.starter.log;
package top.continew.starter.log.handler;
import cn.hutool.core.annotation.AnnotationUtil;
import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.ttl.TransmittableThreadLocal;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import top.continew.starter.log.annotation.Log;
import top.continew.starter.log.enums.Include;
import top.continew.starter.log.http.RecordableHttpRequest;
import top.continew.starter.log.http.RecordableHttpResponse;
import top.continew.starter.log.http.servlet.RecordableServletHttpRequest;
import top.continew.starter.log.http.servlet.RecordableServletHttpResponse;
import top.continew.starter.log.model.AccessLogContext;
import top.continew.starter.log.model.AccessLogProperties;
import top.continew.starter.log.model.LogRecord;
import top.continew.starter.log.util.AccessLogUtils;
import java.lang.reflect.Method;
import java.time.Duration;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
@@ -41,6 +51,9 @@ import java.util.Set;
*/
public abstract class AbstractLogHandler implements LogHandler {
private static final Logger log = LoggerFactory.getLogger(AbstractLogHandler.class);
private final TransmittableThreadLocal<AccessLogContext> logContextThread = new TransmittableThreadLocal<>();
@Override
public LogRecord.Started start(Instant startTime, HttpServletRequest request) {
return LogRecord.start(startTime, new RecordableServletHttpRequest(request));
@@ -156,4 +169,37 @@ public abstract class AbstractLogHandler implements LogHandler {
includes.removeAll(Set.of(excludeArr));
}
}
@Override
public void processAccessLogStartReq(AccessLogContext accessLogContext) {
AccessLogProperties properties = accessLogContext.getProperties().getAccessLog();
// 是否需要打印 规则: 是否打印开关 放行路径
if (!properties.isPrint() || accessLogContext.getProperties()
.getAccessLog()
.isMatch(accessLogContext.getRequest().getPath())) {
return;
}
// 构建上下文
logContextThread.set(accessLogContext);
RecordableHttpRequest request = accessLogContext.getRequest();
String path = request.getPath();
String param = AccessLogUtils.getParam(request, properties);
log.info(param != null ? "[Start] [{}] {} {}" : "[Start] [{}] {}", request.getMethod(), path, param);
}
@Override
public void processAccessLogEndReq(AccessLogContext accessLogContext) {
AccessLogContext logContext = logContextThread.get();
if (ObjectUtil.isNotEmpty(logContext)) {
try {
RecordableHttpRequest request = logContext.getRequest();
RecordableHttpResponse response = accessLogContext.getResponse();
Duration timeTaken = Duration.between(logContext.getStartTime(), accessLogContext.getEndTime());
log.info("[End] [{}] {} {} {}ms", request.getMethod(), request.getPath(), response
.getStatus(), timeTaken.toMillis());
} finally {
logContextThread.remove();
}
}
}
}

View File

@@ -14,11 +14,12 @@
* limitations under the License.
*/
package top.continew.starter.log;
package top.continew.starter.log.handler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import top.continew.starter.log.enums.Include;
import top.continew.starter.log.model.AccessLogContext;
import top.continew.starter.log.model.LogRecord;
import java.lang.reflect.Method;
@@ -97,4 +98,18 @@ public interface LogHandler {
* @return 日志包含信息
*/
Set<Include> getIncludes(Set<Include> includes, Method targetMethod, Class<?> targetClass);
/**
* 处理访问日志开始请求
*
* @param accessLogContext 访问日志上下文
*/
void processAccessLogStartReq(AccessLogContext accessLogContext);
/**
* 处理访问日志 结束请求
*
* @param accessLogContext 访问日志上下文
*/
void processAccessLogEndReq(AccessLogContext accessLogContext);
}

View File

@@ -71,4 +71,11 @@ public interface RecordableHttpRequest {
* @return 请求参数
*/
Map<String, Object> getParam();
/**
* 获取路径 - 格式 /system/dept
*
* @return {@link String }
*/
String getPath();
}

View File

@@ -17,13 +17,10 @@
package top.continew.starter.log.http.servlet;
import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.json.JSONUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.UriUtils;
import org.springframework.web.util.WebUtils;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.log.http.RecordableHttpRequest;
@@ -80,20 +77,21 @@ public final class RecordableServletHttpRequest implements RecordableHttpRequest
@Override
public String getBody() {
ContentCachingRequestWrapper wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
if (null != wrapper) {
String body = StrUtil.utf8Str(wrapper.getContentAsByteArray());
String body = JakartaServletUtil.getBody(request);
return JSONUtil.isTypeJSON(body) ? body : null;
}
return null;
}
@Override
public Map<String, Object> getParam() {
String body = this.getBody();
return CharSequenceUtil.isNotBlank(body) && JSONUtil.isTypeJSON(body)
? JSONUtil.toBean(body, Map.class)
: Collections.unmodifiableMap(request.getParameterMap());
: Collections.unmodifiableMap(JakartaServletUtil.getParamMap(request));
}
@Override
public String getPath() {
return request.getRequestURI();
}
private StringBuilder appendQueryString(String queryString) {

View File

@@ -17,11 +17,9 @@
package top.continew.starter.log.http.servlet;
import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.util.ContentCachingResponseWrapper;
import org.springframework.web.util.WebUtils;
import top.continew.starter.core.wrapper.RepeatReadResponseWrapper;
import top.continew.starter.log.http.RecordableHttpResponse;
import top.continew.starter.web.util.ServletUtils;
@@ -56,10 +54,8 @@ public final class RecordableServletHttpResponse implements RecordableHttpRespon
@Override
public String getBody() {
ContentCachingResponseWrapper wrapper = WebUtils
.getNativeResponse(response, ContentCachingResponseWrapper.class);
if (null != wrapper) {
String body = StrUtil.utf8Str(wrapper.getContentAsByteArray());
if (response instanceof RepeatReadResponseWrapper wrapper && !wrapper.isStreamingResponse()) {
String body = wrapper.getResponseContent();
return JSONUtil.isTypeJSON(body) ? body : null;
}
return null;

View File

@@ -0,0 +1,132 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.log.model;
import top.continew.starter.log.http.RecordableHttpRequest;
import top.continew.starter.log.http.RecordableHttpResponse;
import java.time.Instant;
/**
* 访问日志上下文
*
* @author echo
* @since 2.8.3
*/
public class AccessLogContext {
/**
* 开始时间
*/
private final Instant startTime;
/**
* 结束时间
*/
private Instant endTime;
/**
* 请求信息
*/
private final RecordableHttpRequest request;
/**
* 响应信息
*/
private final RecordableHttpResponse response;
/**
* 配置信息
*/
private final LogProperties properties;
private AccessLogContext(Builder builder) {
this.startTime = builder.startTime;
this.endTime = builder.endTime;
this.request = builder.request;
this.response = builder.response;
this.properties = builder.properties;
}
public Instant getStartTime() {
return startTime;
}
public Instant getEndTime() {
return endTime;
}
public RecordableHttpRequest getRequest() {
return request;
}
public RecordableHttpResponse getResponse() {
return response;
}
public LogProperties getProperties() {
return properties;
}
public void setEndTime(Instant endTime) {
this.endTime = endTime;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Instant startTime;
private Instant endTime;
private RecordableHttpRequest request;
private RecordableHttpResponse response;
private LogProperties properties;
private Builder() {
}
public Builder startTime(Instant startTime) {
this.startTime = startTime;
return this;
}
public Builder endTime(Instant endTime) {
this.endTime = endTime;
return this;
}
public Builder request(RecordableHttpRequest request) {
this.request = request;
return this;
}
public Builder response(RecordableHttpResponse response) {
this.response = response;
return this;
}
public Builder properties(LogProperties properties) {
this.properties = properties;
return this;
}
public AccessLogContext build() {
return new AccessLogContext(this);
}
}
}

View File

@@ -0,0 +1,171 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.log.model;
import top.continew.starter.web.util.SpringWebUtils;
import java.util.ArrayList;
import java.util.List;
/**
* 访问日志输出配置
*
* @author echo
* @since 2.8.3
*/
public class AccessLogProperties {
/**
* 是否打印日志,开启后可打印访问日志(类似于 Nginx access log
* <p>
* 不记录日志也支持开启打印访问日志
* </p>
*/
private boolean isPrint = false;
/**
* 放行路由
*/
private List<String> excludePatterns = new ArrayList<>();
/**
* 是否记录请求参数body/query/form
* <p>开启后会在日志中输出请求参数</p>
*/
private boolean isReqParams = false;
/**
* 是否自动截断超长参数值(如 base64、大文本
* <p>开启后会对超过指定长度的参数值进行截断处理</p>
*/
private boolean truncateLongParams = false;
/**
* 超长参数检测阈值(单位:字符)
* <p>当参数值长度超过此值时,触发截断规则</p>
* <p>默认2000</p>
*/
private int ultraLongParamThreshold = 2000;
/**
* 超长参数最大展示长度(单位:字符)
* <p>当参数超过ultraLongParamThreshold时强制截断到此长度</p>
* <p>默认50</p>
*/
private int ultraLongParamMaxLength = 50;
/**
* 截断后追加的后缀符号(如配置 "..." 会让截断内容更直观)
* <p>建议配置 3-5 个非占宽字符,默认为空不追加</p>
*/
private String truncateSuffix = "...";
/**
* 是否过滤敏感参数
* <p>开启后会对敏感参数进行过滤,默认不过滤</p>
*/
private boolean isSensitiveParams = false;
/**
* 敏感参数字段列表password,token,idCard
* <p>支持精确匹配(区分大小写)</p>
* <p>示例值password,oldPassword</p>
*/
private List<String> sensitiveParamList = new ArrayList<>();
public boolean isPrint() {
return isPrint;
}
public void setPrint(boolean print) {
isPrint = print;
}
public List<String> getExcludePatterns() {
return excludePatterns;
}
public void setExcludePatterns(List<String> excludePatterns) {
this.excludePatterns = excludePatterns;
}
public boolean isReqParams() {
return isReqParams;
}
public void setReqParams(boolean reqParams) {
isReqParams = reqParams;
}
public boolean isTruncateLongParams() {
return truncateLongParams;
}
public void setTruncateLongParams(boolean truncateLongParams) {
this.truncateLongParams = truncateLongParams;
}
public int getUltraLongParamThreshold() {
return ultraLongParamThreshold;
}
public void setUltraLongParamThreshold(int ultraLongParamThreshold) {
this.ultraLongParamThreshold = ultraLongParamThreshold;
}
public int getUltraLongParamMaxLength() {
return ultraLongParamMaxLength;
}
public void setUltraLongParamMaxLength(int ultraLongParamMaxLength) {
this.ultraLongParamMaxLength = ultraLongParamMaxLength;
}
public String getTruncateSuffix() {
return truncateSuffix;
}
public void setTruncateSuffix(String truncateSuffix) {
this.truncateSuffix = truncateSuffix;
}
public boolean isSensitiveParams() {
return isSensitiveParams;
}
public void setSensitiveParams(boolean sensitiveParams) {
isSensitiveParams = sensitiveParams;
}
public List<String> getSensitiveParamList() {
return sensitiveParamList;
}
public void setSensitiveParamList(List<String> sensitiveParamList) {
this.sensitiveParamList = sensitiveParamList;
}
/**
* 是否匹配放行路由
*
* @param uri 请求 URI
* @return 是否匹配
*/
public boolean isMatch(String uri) {
return this.getExcludePatterns().stream().anyMatch(pattern -> SpringWebUtils.isMatch(uri, pattern));
}
}

View File

@@ -17,6 +17,7 @@
package top.continew.starter.log.model;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
import top.continew.starter.core.constant.PropertiesConstants;
import top.continew.starter.log.enums.Include;
import top.continew.starter.web.util.SpringWebUtils;
@@ -40,12 +41,10 @@ public class LogProperties {
private boolean enabled = true;
/**
* 是否打印日志,开启后可打印访问日志(类似于 Nginx access log
* <p>
* 不记录日志也支持开启打印访问日志
* </p>
* 访问日志配置
*/
private Boolean isPrint = false;
@NestedConfigurationProperty
private AccessLogProperties accessLog = new AccessLogProperties();
/**
* 包含信息
@@ -65,14 +64,6 @@ public class LogProperties {
this.enabled = enabled;
}
public Boolean getIsPrint() {
return isPrint;
}
public void setIsPrint(Boolean print) {
isPrint = print;
}
public Set<Include> getIncludes() {
return includes;
}
@@ -89,6 +80,14 @@ public class LogProperties {
this.excludePatterns = excludePatterns;
}
public AccessLogProperties getAccessLog() {
return accessLog;
}
public void setAccessLog(AccessLogProperties accessLog) {
this.accessLog = accessLog;
}
/**
* 是否匹配放行路由
*

View File

@@ -0,0 +1,118 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.log.util;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONUtil;
import top.continew.starter.log.http.RecordableHttpRequest;
import top.continew.starter.log.model.AccessLogProperties;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author echo
* @since 2025/03/25 19:16
**/
public class AccessLogUtils {
public AccessLogUtils() {
}
/**
* 获取参数信息
*
* @param request 请求
* @param properties 属性
* @return {@link String }
*/
public static String getParam(RecordableHttpRequest request, AccessLogProperties properties) {
// 是否需要输出参数
if (!properties.isReqParams()) {
return null;
}
// 参数为空返回空
Map<String, Object> params = request.getParam();
if (ObjectUtil.isEmpty(params) || params.isEmpty()) {
return null;
}
// 是否需要对特定入参脱敏
if (properties.isSensitiveParams()) {
params = filterSensitiveParams(params, properties.getSensitiveParamList());
}
// 是否自动截断超长参数值
if (properties.isTruncateLongParams()) {
params = truncateLongParams(params, properties.getUltraLongParamThreshold(), properties
.getUltraLongParamMaxLength(), properties.getTruncateSuffix());
}
return "param:" + JSONUtil.toJsonStr(params);
}
/**
* 过滤敏感参数
*
* @param params 参数 Map
* @param sensitiveParams 敏感参数列表
* @return 处理后的参数 Map
*/
private static Map<String, Object> filterSensitiveParams(Map<String, Object> params, List<String> sensitiveParams) {
if (params == null || params.isEmpty() || sensitiveParams == null || sensitiveParams.isEmpty()) {
return params;
}
Map<String, Object> filteredParams = new HashMap<>(params);
for (String sensitiveKey : sensitiveParams) {
if (filteredParams.containsKey(sensitiveKey)) {
filteredParams.put(sensitiveKey, "***");
}
}
return filteredParams;
}
/**
* 截断超长参数
*
* @param params 参数 Map
* @param threshold 截断阈值(值长度超过该值才截断)
* @param maxLength 最大长度
* @param suffix 后缀(如 "..."
* @return 处理后的参数 Map
*/
private static Map<String, Object> truncateLongParams(Map<String, Object> params,
int threshold,
int maxLength,
String suffix) {
if (params == null || params.isEmpty()) {
return params;
}
Map<String, Object> truncatedParams = new HashMap<>(params);
for (Map.Entry<String, Object> entry : truncatedParams.entrySet()) {
Object value = entry.getValue();
if (value instanceof String strValue) {
if (strValue.length() > threshold) {
entry.setValue(strValue.substring(0, Math.min(strValue.length(), maxLength)) + suffix);
}
}
}
return truncatedParams;
}
}

View File

@@ -30,8 +30,8 @@ import top.continew.starter.log.annotation.ConditionalOnEnabledLog;
import top.continew.starter.log.dao.LogDao;
import top.continew.starter.log.dao.impl.DefaultLogDaoImpl;
import top.continew.starter.log.handler.InterceptorLogHandler;
import top.continew.starter.log.LogFilter;
import top.continew.starter.log.LogHandler;
import top.continew.starter.log.filter.LogFilter;
import top.continew.starter.log.handler.LogHandler;
import top.continew.starter.log.interceptor.LogInterceptor;
import top.continew.starter.log.model.LogProperties;

View File

@@ -16,8 +16,6 @@
package top.continew.starter.log.handler;
import top.continew.starter.log.AbstractLogHandler;
/**
* 日志处理器-拦截器版实现
*

View File

@@ -27,13 +27,15 @@ import org.springframework.lang.NonNull;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import top.continew.starter.log.annotation.Log;
import top.continew.starter.log.http.servlet.RecordableServletHttpRequest;
import top.continew.starter.log.http.servlet.RecordableServletHttpResponse;
import top.continew.starter.log.model.AccessLogContext;
import top.continew.starter.log.model.LogProperties;
import top.continew.starter.log.dao.LogDao;
import top.continew.starter.log.LogHandler;
import top.continew.starter.log.handler.LogHandler;
import top.continew.starter.log.model.LogRecord;
import java.lang.reflect.Method;
import java.time.Duration;
import java.time.Instant;
/**
@@ -62,10 +64,11 @@ public class LogInterceptor implements HandlerInterceptor {
@NonNull HttpServletResponse response,
@NonNull Object handler) {
Instant startTime = Instant.now();
if (Boolean.TRUE.equals(logProperties.getIsPrint())) {
log.info("[{}] {}", request.getMethod(), request.getRequestURI());
timeTtl.set(startTime);
}
logHandler.processAccessLogStartReq(AccessLogContext.builder()
.startTime(startTime)
.request(new RecordableServletHttpRequest(request))
.properties(logProperties)
.build());
// 开始日志记录
if (this.isRequestRecord(handler, request)) {
LogRecord.Started startedLogRecord = logHandler.start(startTime, request);
@@ -81,11 +84,10 @@ public class LogInterceptor implements HandlerInterceptor {
Exception e) {
try {
Instant endTime = Instant.now();
if (Boolean.TRUE.equals(logProperties.getIsPrint())) {
Duration timeTaken = Duration.between(timeTtl.get(), endTime);
log.info("[{}] {} {} {}ms", request.getMethod(), request.getRequestURI(), response
.getStatus(), timeTaken.toMillis());
}
logHandler.processAccessLogEndReq(AccessLogContext.builder()
.endTime(endTime)
.response(new RecordableServletHttpResponse(response, response.getStatus()))
.build());
LogRecord.Started startedLogRecord = logTtl.get();
if (null == startedLogRecord) {
return;