概述
项目里使用了Feign进行远程调用,有时为了便于排查问题,需要记录请求和响应日志,下面简介一下如何保存Feign日志到数据库(Redis/MongoDB):
- 重写
FeignClient
记录日志 - 使用
Aspect
切面记录日志
本文依赖:
- spring-boot-starter-parent:2.4.2
- spring-cloud-starter-openfeign:3.0.0
重写FeignClient
记录日志
那么怎么才能让OpenFeign
记录请求和响应日志呢?
默认情况下,OpenFeign使用feign.Client.Default
发起http请求。我们可以重写Client,并注入Bean来替换掉feign.Client.Default
,从而实现日志记录,当然也可以做其它事情,比如添加Header。
通过对源码feign.SynchronousMethodHandler#executeAndDecode
response = client.execute(request, options);
分析不难发现:执行request请求以及接收response响应的是feign.Client
(默认feign.Client.Default
1)。重写这个Client
,spring 容器启动的时候创建我们重写的Client
便可以实现。由于feign提供的Response.class
是final类型,导致我们没有办法进行流copy,所以我们需要创建一个类似BufferingClientHttpRequestFactory
东西进行流copy。
在FeignClient中配置
@FeignClient(url = "${weather.api.url}", name = "logFeignClient", configuration = FeignConfiguration.class)
编写FeignConfiguration
public class FeignConfiguration {
@Bean
public Client feignClient() {
return new LogClient(null, null);
}
}
重写Client
@Slf4j
public class LogClient extends Client.Default {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public LogClient(SSLSocketFactory socketFactory, HostnameVerifier hostnameVerifier) {
super(socketFactory, hostnameVerifier);
}
@Override
public Response execute(Request request, Request.Options options) throws IOException {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
Exception exception = null;
BufferingFeignClientResponse bufferingFeignClientResponse = null;
try {
bufferingFeignClientResponse = new BufferingFeignClientResponse(super.execute(request, options));
} catch (Exception exp) {
log.error(exp.getMessage(), exp);
exception = exp;
throw exp;
} finally {
stopWatch.stop();
this.logAndSave(request, bufferingFeignClientResponse, stopWatch, exception);
}
Response response = bufferingFeignClientResponse.getResponse().toBuilder()
.body(bufferingFeignClientResponse.getBody(), bufferingFeignClientResponse.getResponse().body().length())
.build();
bufferingFeignClientResponse.close();
return response;
}
private void logAndSave(Request request, BufferingFeignClientResponse bufferingFeignClientResponse, StopWatch stopWatch, Exception exception) {
// 组装request及response信息
StringBuilder sb = new StringBuilder("[log started]\r\n");
sb.append(request.httpMethod()).append(" ").append(request.url()).append("\r\n");
// 请求Header
combineHeaders(sb, request.headers());
combineRequestBody(sb, request.body(), request.requestTemplate().queries());
sb.append("\r\nResponse cost time(ms): ").append(stopWatch.getLastTaskTimeMillis());
if (bufferingFeignClientResponse != null) {
sb.append(" status: ").append(bufferingFeignClientResponse.status());
}
sb.append("\r\n");
if (bufferingFeignClientResponse != null) {
// 响应Header
combineHeaders(sb, bufferingFeignClientResponse.headers());
combineResponseBody(sb, bufferingFeignClientResponse.toBodyString(), bufferingFeignClientResponse.headers().get(HttpHeaders.CONTENT_TYPE));
}
if (exception != null) {
sb.append("Exception:\r\n ").append(exception.getMessage()).append("\r\n");
}
sb.append("\r\n[log ended]");
log.debug(sb.toString());
// 保存日志信息至缓存,可替换成MySQL或者MongoDB存储
redisTemplate.opsForValue().set("sbLog" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")), sb.toString());
}
private static void combineHeaders(StringBuilder sb, Map<String, Collection<String>> headers) {
if (headers != null && !headers.isEmpty()) {
sb.append("Headers:\r\n");
for (Map.Entry<String, Collection<String>> ob : headers.entrySet()) {
for (String val : ob.getValue()) {
sb.append(" ").append(ob.getKey()).append(": ").append(val).append("\r\n");
}
}
}
}
private static void combineRequestBody(StringBuilder sb, byte[] body, Map<String, Collection<String>> params) {
if (params != null) {
sb.append("Request Params:\r\n").append(" ").append(params).append("\r\n");
}
if (body != null && body.length > 0) {
sb.append("Request Body:\r\n").append(" ").append(new String(body)).append("\r\n");
}
}
private static void combineResponseBody(StringBuilder sb, String respStr, Collection<String> collection) {
if (respStr == null) {
return;
}
if (collection.contains(MediaType.APPLICATION_JSON_VALUE)) {
try {
respStr = JSON.parseObject(respStr).toString();
//no care this exception
} catch (JSONException ignored) {
}
}
sb.append("Body:\r\n").append(respStr).append("\r\n");
}
static final class BufferingFeignClientResponse implements Closeable {
private final Response response;
private byte[] body;
private BufferingFeignClientResponse(Response response) {
this.response = response;
}
private Response getResponse() {
return this.response;
}
private int status() {
return this.response.status();
}
private Map<String, Collection<String>> headers() {
return this.response.headers();
}
private String toBodyString() {
try {
return new String(toByteArray(getBody()), UTF_8);
} catch (Exception e) {
return super.toString();
}
}
private InputStream getBody() throws IOException {
if (this.body == null) {
this.body = StreamUtils.copyToByteArray(this.response.body().asInputStream());
}
return new ByteArrayInputStream(this.body);
}
@Override
public void close() {
ensureClosed(response);
}
}
}
使用Aspect
切面记录日志
这个不推荐,因为它无法打印出具体的url、header等数据,有兴趣的可以看看全局记录Feign的请求和响应日志,手动AOP这两篇文章