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

@@ -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,12 +77,8 @@ 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());
return JSONUtil.isTypeJSON(body) ? body : null;
}
return null;
String body = JakartaServletUtil.getBody(request);
return JSONUtil.isTypeJSON(body) ? body : null;
}
@Override
@@ -93,7 +86,12 @@ public final class RecordableServletHttpRequest implements RecordableHttpRequest
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;
}
}