如何优雅实现Logback MDC数值类型自动识别,避免Elasticsearch类型映射错误
我们的应用使用SLF4J的MDC和Logback的JSON编码器将日志行输出为JSON格式,这些日志行随后会被日志采集管道处理,作为文档写入Elasticsearch。我们的logback.xml配置如下:
<configuration> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <provider class="net.logstash.logback.composite.loggingevent.ArgumentsJsonProvider"/> </encoder> </appender> <root level="INFO"> <appender-ref ref="STDOUT" /> </root> </configuration>
MDC的使用方式如下:
MDC.put("stringVal", "val"); MDC.put("decimalVal", "1.23"); MDC.put("longVal", "123498559348792879267942876"); logger.info("foo");
问题在于MDC的接口定义是void put(String key, String val),所有值都必须是字符串或者可自动装箱为字符串,这会生成如下格式的日志:
{"@timestamp":"2021-08-23T16:32:04.231+01:00","@version":1,"message":"foo","logger_name":"MDCTest","thread_name":"main","level":"INFO","level_value":20000,"stringVal":"val","decimalVal":"1.23","longVal":"123498559348792879267942876"}
字符串类型的decimalVal和longVal会被Elasticsearch的自动类型映射识别为字符串类型,导致我们无法对这些字段执行数值运算。
当前这些数值大多来自用日志传递指标的不合理使用,不过这个混淆问题正在其他环节处理。
我不希望开发者新增日志时还要更新Elasticsearch索引模板或者编写日志采集管道的配置,因此一直在寻找自动实现类型转换的方法。我已经实现了一个方案,通过替换Logback的Encoder和Formatter内部逻辑,替换负责将MDC序列化为JSON的MdcJsonProvider类来实现需求,但该方案既脆弱性能也不佳。
有没有更优雅的实现方法,或者其他可以达成相同效果的路径?我已经调研过ch.qos.logback.contrib.jackson.JacksonJsonFormatter,但它仍需要在logback配置文件中罗列数值类型的MDC属性,这是我想要避免的。
import java.io.IOException; import java.math.BigDecimal; import java.math.BigInteger; import java.util.HashMap; import java.util.Map; import java.util.Optional; import com.fasterxml.jackson.core.JsonGenerator; import com.google.common.collect.Maps; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.spi.ContextAware; import net.logstash.logback.LogstashFormatter; import net.logstash.logback.composite.CompositeJsonFormatter; import net.logstash.logback.composite.JsonProvider; import net.logstash.logback.composite.JsonWritingUtils; import net.logstash.logback.composite.loggingevent.MdcJsonProvider; import net.logstash.logback.encoder.LogstashEncoder; /** * 该类是一个临时hack方案 * * 它的存在是因为Logback的MDC接口仅支持String到String的映射,且该规则无法修改。因此我们只能修改日志器本身,检查字符串值是否为数值类型,若是则按数值类型输出日志。 * * 由于我们直接将日志文档写入Elasticsearch,因此需要注意:如果同一个键对应的类型频繁变化,或者偶尔写入全数值格式的字符串,可能会出现映射冲突问题。 * * 该类实现了协同工作的Encoder、Formatter和JsonProvider。编码器指定了格式化器,格式化器会将原本负责序列化MDC值的MdcJsonProvider替换为自定义实现,尝试按数值类型输出MDC值。 * * 使用该类会有额外性能开销:会产生更多短生命周期对象,因此不适用于高频日志场景。 */ public class JsonMdcTypePreservingEncoder extends LogstashEncoder { protected CompositeJsonFormatter<ILoggingEvent> createFormatter() { return new JsonMdcTypePreservingFormatter(this); } protected JsonMdcTypePreservingFormatter getFormatter() { return (JsonMdcTypePreservingFormatter) super.getFormatter(); } /** * 该类的作用是移除默认负责将MDC序列化为JSON的MdcJsonProvider,替换为自定义实现。 */ public static class JsonMdcTypePreservingFormatter extends LogstashFormatter { public JsonMdcTypePreservingFormatter(ContextAware declaredOrigin) { super(declaredOrigin); Optional<JsonProvider<ILoggingEvent>> oldProvider = getProviders().getProviders() .stream() .filter(o -> o.getClass() == MdcJsonProvider.class) .findFirst(); if (oldProvider.isPresent()) { getProviders().removeProvider(oldProvider.get()); getProviders().addProvider(new TypePreservingMdcJsonProvider()); } } /** * 该类复制了MdcJsonProvider.writeTo的实现,仅做了一处小修改:不再直接序列化MDC的Map<String, String>,而是生成一个修改后的Map<String, Object>,其中可能同时包含BigDecimal、BigInteger类型和字符串类型,再序列化该Map。 * * 该方法中唯一新增的代码是getStringObjectMap的调用。 */ public static class TypePreservingMdcJsonProvider extends MdcJsonProvider { private Map<String, Object> convertedProperties = Maps.newHashMap(); @Override public void writeTo(JsonGenerator generator, ILoggingEvent event) throws IOException { Map<String, String> mdcProperties = event.getMDCPropertyMap(); if (mdcProperties != null && !mdcProperties.isEmpty()) { if (getFieldName() != null) { generator.writeObjectFieldStart(getFieldName()); } if (!getIncludeMdcKeyNames().isEmpty()) { mdcProperties = new HashMap(mdcProperties); ((Map) mdcProperties).keySet().retainAll(getIncludeMdcKeyNames()); } if (!getExcludeMdcKeyNames().isEmpty()) { mdcProperties = new HashMap(mdcProperties); ((Map) mdcProperties).keySet().removeAll(getExcludeMdcKeyNames()); } Map<String, Object> convertedProperties = getStringObjectMap(mdcProperties); JsonWritingUtils.writeMapEntries(generator, convertedProperties); if (getFieldName() != null) { generator.writeEndObject(); } } } private Map<String, Object> getStringObjectMap(Map<String, String> mdcProperties) { convertedProperties.clear(); for (String key : mdcProperties.keySet()) { String value = mdcProperties.get(key); // 如果大多数MDC值都不是数值,该逻辑会避免不必要的解析步骤,但会为每个值增加逐字符检查的开销。 try { BigInteger parsed = new BigInteger(value); convertedProperties.put(key, parsed); } catch (NumberFormatException e) { try { BigDecimal parsed = new BigDecimal(value); convertedProperties.put(key, parsed); } catch (NumberFormatException f) { // No-op } } if (!convertedProperties.containsKey(key)) { convertedProperties.put(key, value); } } return convertedProperties; } } } }
感谢各位提供的建议!
内容的提问来源于stack exchange,提问作者Martin Foot
相关产品推荐
相关产品推荐

