查看原文
其他

【306期】微服务 Spring Cloud 架构实现分布式日志采集方案

Java精选 2022-08-09

点击上方“Java精选”,选择“设为星标”

别问别人为什么,多问自己凭什么!

下方有惊喜,留言必回,有问必答!

每一天进步一点点,是成功的开始...

一、简介

分布式应用必须有一套日志采集功能,目的是将分布在各个服务器节点上的应用日志文件采集到统一的服务器上,方便日志的查看。springCloud本身提供了基于elk的日志采集,但是由于使用logstash,会加大运维成本。这里将使用轻量级的方案。

二、思路

我们的目的是提供轻量级的日志采集来代替logstash,日志最终还是会存进Elasticsearch。为了能轻量级的实现日志采集,并且避免对代码的侵入,我们可以扩展Logback的appender,也可以扩展log4j的appender。这样我们使用slf4j来记录日志的时候,日志自动会保存到Elasticsearch中,并且不用修改任何业务代码。

三、自定义Logback appender

我们先来看一下Logback的appender的Uml图,我们可以发现两个对我们有借鉴意义的类

  • UnsynchronizedAppenderBase提供了异步的日志记录
  • DBAppender基于数据库的日志记录

这两个类还是比较简单的,具体的代码我就不详细解说了,请自行查阅

属性注入

基本实现逻辑从UnsynchronizedAppenderBaseDBAppender已经能够知道了,现在把我们需要的信息注入到Appender中,这里需要如下的知识

Logback标签注入属性

我们可以直接在Xml中用标签配置属性,这些标签只要名称和appender中的成员变量名一致,则会自动把标签中的属性注入到成员变量中。

我们举一个例子:

xml这样配置

<appender name="ES" class="com.luminroy.component.logger.appender.ElasticsearchAppender">
  <profile>test</profile>
  <esType>demo</esType>
  <withJansi>true</withJansi>
  <encoder>
   <pattern>${CONSOLE_LOG_PATTERN_IDE}</pattern>
   <charset>utf8</charset>
  </encoder>
 </appender>

其中ElasticsearchAppender是我们自己实现的Appender。这里有一个profile标签,我们需要ElasticsearchAppender中成员变量的名称和该标签名一致,这样就可以把test值注入到成员变量profile中。

protected String profile = ""// 运行环境
Spring配置信息注入属性

有些信息可能已经在spring中做了配置,我们不想要重复的配置,这个时候我们可以用springProperty标签来进行设置。

  • scope:作用范围
  • name:名称
  • source:spring配置
  • defaultValue:默认值,必须要指定

然后在标签中用上面的name属性作为占位符,类中的成员变量名和标签名一致。

我们举一个例子:

xml这样配置

<springProperty scope="context" name="applicationName" source="spring.application.name"
     defaultValue=""/>

 <springProperty scope="context" name="profile" source="spring.profiles.active"
     defaultValue="default"/>

 
 <springProperty scope="context" name="esUserName" source="luminary.elasticsearch.username"
     defaultValue="elastic"/>

 
 <springProperty scope="context" name="esPassword" source="luminary.elasticsearch.password"
     defaultValue="123456"/>

 
 <springProperty scope="context" name="esServer" source="luminary.elasticsearch.server"
     defaultValue="127.0.0.1:9200"/>

 
 <springProperty scope="context" name="esMultiThreaded" source="luminary.elasticsearch.multiThreaded"
     defaultValue="true"/>

 
 <springProperty scope="context" name="esMaxTotalConnection" source="luminary.elasticsearch.maxTotalConnection"
     defaultValue="20"/>

 
 <springProperty scope="context" name="esMaxTotalConnectionPerRoute" source="luminary.elasticsearch.maxTotalConnectionPerRoute"
     defaultValue="5"/>

 
 <springProperty scope="context" name="esDiscoveryEnabled" source="luminary.elasticsearch.discoveryEnabled"
     defaultValue="true"/>

 
 <springProperty scope="context" name="esDiscorveryFrequency" source="luminary.elasticsearch.discorveryFrequency"
     defaultValue="60"/>

<appender name="ES" class="com.luminary.component.logger.appender.SpringElasticsearchAppender">
  <applicationName>${applicationName}</applicationName>
  <profile>${profile}</profile>
  <esType>demo</esType>
  <username>${esUserName}</username>
  <password>${esPassword}</password>
  <server>${esServer}</server>
  <multiThreaded>${esMultiThreaded}</multiThreaded>
  <maxTotalConnection>${esMaxTotalConnection}</maxTotalConnection>
  <maxTotalConnectionPerRoute>${esMaxTotalConnectionPerRoute}</maxTotalConnectionPerRoute>
  <discoveryEnabled>${esDiscoveryEnabled}</discoveryEnabled>
  <discorveryFrequency>${esDiscorveryFrequency}</discorveryFrequency>
 </appender>

yml这样配置

spring:
  application:
    name: logger-demo-server
 
luminary: 
  elasticsearch:
    username: elastic
    password: 123456
    server: 
      - 127.0.0.1:9200
    multiThreaded: true
    maxTotalConnection: 20
    maxTotalConnectionPerRoute: 5
    discoveryEnabled: true
    discorveryFrequency: 60

成员变量

@Setter
protected String esIndex = "java-log-#date#"// 索引
@Setter
protected String esType = "java-log"// 类型
@Setter
protected boolean isLocationInfo = true// 是否打印行号
@Setter
protected String applicationName = "";
@Setter
protected String profile = ""// 运行环境
@Setter
protected String esAddress = ""// 地址
Logback代码注入属性

这里还有一种情况,有些属性需要在运行时才知道,或者运行时会改变。这就需要能动态注入属性。我们可以使用log4j的MDC类来解决。

我们可以通过相应的put,remove方法来动态设置属性。

比如:

MDC.put(TraceInfo.TRACE_ID_KEY, traceInfo.getTraceId());
MDC.put(TraceInfo.RPC_ID_KEY, traceInfo.getRpcId());
MDC.remove(TraceInfo.TRACE_ID_KEY);
MDC.remove(TraceInfo.RPC_ID_KEY);

获取属性值可以通过LoggingEventgetMDCPropertyMap方法先获取属性的map,再根据键名从map中取出来。

比如:

private String getRpcId(LoggingEvent event) {
    Map<String, String> mdcPropertyMap = event.getMDCPropertyMap();
 return mdcPropertyMap.get("rpcId");
}
 
private String getTraceId(LoggingEvent event) {
 Map<String, String> mdcPropertyMap = event.getMDCPropertyMap();
 return mdcPropertyMap.get("traceId");
}

值得说明的是,mdcAdapter是一个静态的成员变量,但是它自身是线程安全的,我们可以看一下logback的实现

private Map<String, String> duplicateAndInsertNewMap(Map<String, String> oldMap) {
        Map<String, String> newMap = Collections.synchronizedMap(new HashMap<String, String>());
        if (oldMap != null) {
            // we don't want the parent thread modifying oldMap while we are
            // iterating over it
            synchronized (oldMap) {
                newMap.putAll(oldMap);
            }
        }
 
        copyOnThreadLocal.set(newMap);
        return newMap;
    }
Elasticsearch模板设计

最后日志保存在Elasticsearch中,我们希望索引名为java-log-${date}的形式,type名为实际的微服务名

最后我们对日志索引设置一个模板

举一个例子:

PUT _template/java-log
{
  "template""java-log-*",
  "order": 0,
  "setting": {
    "index": {
        "refresh_interval""5s"
    }
  },
  "mappings": {
    "_default_": {
      "dynamic_templates": [
        {
          "message_field": {
            "match_mapping_type""string",
            "path_match""message",
            "mapping": {
              "norms"false,
              "type""text",
              "analyzer""ik_max_word",
              "search_analyzer""ik_max_word"
            }
          }
        },
        {
          "throwable_field": {
            "match_mapping_type""string",
            "path_match""throwable",
            "mapping": {
              "norms"false,
              "type""text",
              "analyzer""ik_max_word",
              "search_analyzer""ik_max_word"
            }
          }
        },
        {
          "string_field": {
            "match_mapping_type""string",
            "match""*",
            "mapping": {
              "norms"false,
              "type""text",
              "analyzer""ik_max_word",
              "search_analyzer""ik_max_word",
              "fields": {
                  "keyword": {
                    "type""keyword"
                  }
              }
            }
          }
        }
      ],
      "_all": {
        "enabled"false
      },
      "properties": {
       "applicationName": {
          "norms"false,
          "type""text",
          "analyzer""ik_max_word",
          "search_analyzer""ik_max_word",
          "fields": {
           "keyword": {
             "type""keyword",
              "ignore_above": 256
           }
          }
        },
        "profile": {
          "type""keyword"
        },
        "host": {
          "type""keyword"
        },
        "ip": {
          "type""ip"
        },
        "level": {
          "type""keyword"
        },
        "location": {
          "properties": {
            "line": {
               "type""integer"
            }
          }
        },
        "dateTime": {
          "type""date"
        },
        "traceId": {
          "type""keyword"
        },
        "rpcId": {
          "type""keyword"
        }
      }
    }
  }
}

示例代码

@Slf4j
public class ElasticsearchAppender<E> extends UnsynchronizedAppenderBase<E> implements LuminaryLoggerAppender<E> {
 
 private static final FastDateFormat SIMPLE_FORMAT = FastDateFormat.getInstance("yyyy-MM-dd");
 
 private static final FastDateFormat ISO_DATETIME_TIME_ZONE_FORMAT_WITH_MILLIS = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSSZZ");
 
 protected JestClient jestClient;
 
 private static final String CONFIG_PROPERTIES_NAME = "es.properties";
 
 // 可在xml中配置的属性
 @Setter
 protected String esIndex = "java-log-#date#"// 索引
 @Setter
 protected String esType = "java-log"// 类型
 @Setter
 protected boolean isLocationInfo = true// 是否打印行号
 @Setter
 protected String applicationName = "";
 @Setter
 protected String profile = ""// 运行环境
 @Setter
 protected String esAddress = ""// 地址
 
 @Override
 public void start() {
  super.start();
  init();
 }
 
 @Override
 public void stop() {
  super.stop();
  // 关闭es客户端
  try {
   jestClient.close();
  } catch (IOException e) {
   addStatus(new ErrorStatus("close jestClient fail"this, e));
  }
 }
 
    @Override
    protected void append(E event) {
      if (!isStarted()) {
             return;
         }
 
      subAppend(event);
    }
 
    private void subAppend(E event) {
     if (!isStarted()) {
            return;
        }
     
     try {
            // this step avoids LBCLASSIC-139
            if (event instanceof DeferredProcessingAware) {
                ((DeferredProcessingAware) event).prepareForDeferredProcessing();
            }
            // the synchronization prevents the OutputStream from being closed while we
            // are writing. It also prevents multiple threads from entering the same
            // converter. Converters assume that they are in a synchronized block.
            save(event);
        } catch (Exception ioe) {
            // as soon as an exception occurs, move to non-started state
            // and add a single ErrorStatus to the SM.
            this.started = false;
            addStatus(new ErrorStatus("IO failure in appender"this, ioe));
        }
    }
    
    private void save(E event) {
     if(event instanceof LoggingEvent) {
      // 获得日志数据
   EsLogVO esLogVO = createData((LoggingEvent) event);
   // 保存到es中
   save(esLogVO);
     } else {
      addWarn("the error type of event!");
     }
    }
 
 private void save(EsLogVO esLogVO) {
  Gson gson = new Gson();
  String jsonString = gson.toString();
 
  String esIndexFormat = esIndex.replace("#date#", SIMPLE_FORMAT.format(Calendar.getInstance().getTime()));
  Index index = new Index.Builder(esLogVO).index(esIndexFormat).type(esType).build();
 
  try {
   DocumentResult result = jestClient.execute(index);
   addStatus(new InfoStatus("es logger result:"+result.getJsonString(), this));
  } catch (Exception e) {
   addStatus(new ErrorStatus("jestClient exec fail"this, e));
  }
 }
 
 private EsLogVO createData(LoggingEvent event) {
  EsLogVO esLogVO = new EsLogVO();
 
  // 获得applicationName
  esLogVO.setApplicationName(applicationName);
  
  // 获得profile
  esLogVO.setProfile(profile);
  
  // 获得ip
  esLogVO.setIp(HostUtil.getIP());
 
  // 获得hostName
  esLogVO.setHost(HostUtil.getHostName());
 
  // 获得时间
  long dateTime = getDateTime(event);
  esLogVO.setDateTime(ISO_DATETIME_TIME_ZONE_FORMAT_WITH_MILLIS.format(Calendar.getInstance().getTime()));
 
  // 获得线程
  String threadName = getThead(event);
  esLogVO.setThread(threadName);
 
  // 获得日志等级
  String level = getLevel(event);
  esLogVO.setLevel(level);
 
  // 获得调用信息
  EsLogVO.Location location = getLocation(event);
  esLogVO.setLocation(location);
 
  // 获得日志信息
  String message = getMessage(event);
  esLogVO.setMessage(message);
 
  // 获得异常信息
  String throwable = getThrowable(event);
  esLogVO.setThrowable(throwable);
 
  // 获得traceId
  String traceId = getTraceId(event);
  esLogVO.setTraceId(traceId);
 
  // 获得rpcId
  String rpcId = getRpcId(event);
  esLogVO.setRpcId(rpcId);
 
  return esLogVO;
 }
 
 private String getRpcId(LoggingEvent event) {
  Map<String, String> mdcPropertyMap = event.getMDCPropertyMap();
  return mdcPropertyMap.get("rpcId");
 }
 
 private String getTraceId(LoggingEvent event) {
  Map<String, String> mdcPropertyMap = event.getMDCPropertyMap();
  return mdcPropertyMap.get("traceId");
 }
 
 private String getThrowable(LoggingEvent event) {
  String exceptionStack = "";
  IThrowableProxy tp = event.getThrowableProxy();
  if (tp == null)
   return "";
 
  StringBuilder sb = new StringBuilder(2048);
  while (tp != null) {
 
   StackTraceElementProxy[] stackArray = tp.getStackTraceElementProxyArray();
 
   ThrowableProxyUtil.subjoinFirstLine(sb, tp);
 
   int commonFrames = tp.getCommonFrames();
   StackTraceElementProxy[] stepArray = tp.getStackTraceElementProxyArray();
   for (int i = 0; i < stepArray.length - commonFrames; i++) {
    sb.append("\n");
    sb.append(CoreConstants.TAB);
    ThrowableProxyUtil.subjoinSTEP(sb, stepArray[i]);
   }
 
   if (commonFrames > 0) {
    sb.append("\n");
    sb.append(CoreConstants.TAB).append("... ").append(commonFrames).append(" common frames omitted");
   }
 
   sb.append("\n");
 
   tp = tp.getCause();
  }
  return sb.toString();
 }
 
 private String getMessage(LoggingEvent event) {
  return event.getFormattedMessage();
 }
 
 private EsLogVO.Location getLocation(LoggingEvent event) {
  EsLogVO.Location location = new EsLogVO.Location();
  if(isLocationInfo) {
   StackTraceElement[] cda = event.getCallerData();
   if (cda != null && cda.length > 0) {
    StackTraceElement immediateCallerData = cda[0];
    location.setClassName(immediateCallerData.getClassName());
    location.setMethod(immediateCallerData.getMethodName());
    location.setFile(immediateCallerData.getFileName());
    location.setLine(String.valueOf(immediateCallerData.getLineNumber()));
   }
  }
  return location;
 }
 
 private String getLevel(LoggingEvent event) {
  return event.getLevel().toString();
 }
 
 private String getThead(LoggingEvent event) {
  return event.getThreadName();
 }
 
 private long getDateTime(LoggingEvent event) {
  return ((LoggingEvent) event).getTimeStamp();
 }
 
    private void init() {
  try {
   ClassLoader esClassLoader = ElasticsearchAppender.class.getClassLoader();
   Set<URL> esConfigPathSet = new LinkedHashSet<URL>();
   Enumeration<URL> paths;
   if (esClassLoader == null) {
    paths = ClassLoader.getSystemResources(CONFIG_PROPERTIES_NAME);
   } else {
    paths = esClassLoader.getResources(CONFIG_PROPERTIES_NAME);
   }
   while (paths.hasMoreElements()) {
    URL path = paths.nextElement();
    esConfigPathSet.add(path);
   }
 
   if(esConfigPathSet.size() == 0) {
    subInit();
    if(jestClient == null) {
     addWarn("没有获取到配置信息!");
     // 用默认信息初始化es客户端
     jestClient = new JestClientMgr().getJestClient();
    }
   } else {
 
    if (esConfigPathSet.size() > 1) {
     addWarn("获取到多个配置信息,将以第一个为准!");
    }
 
    URL path = esConfigPathSet.iterator().next();
    try {
     Properties config = new Properties();
     @Cleanup InputStream input = new FileInputStream(path.getPath());
     config.load(input);
     // 通过properties初始化es客户端
     jestClient = new JestClientMgr(config).getJestClient();
    } catch (Exception e) {
     addStatus(new ErrorStatus("config fail"this, e));
    }
 
   }
  } catch (Exception e) {
   addStatus(new ErrorStatus("config fail"this, e));
  }
 }
 
 @Override
 public void subInit() {
  // template method
 }
    
}
代码地址:

https://github.com/wulinfeng2/luminary-component

作者:guduyishuai

https://blog.csdn.net/guduyishuai/article/details/81356000

公众号“Java精选”所发表内容注明来源的,版权归原出处所有(无法查证版权的或者未注明出处的均来自网络,系转载,转载的目的在于传递更多信息,版权属于原作者。如有侵权,请联系,笔者会第一时间删除处理!

------ THE END ------

精品资料,超赞福利!

☆ 主流Java进阶技术(学习资料分享)


3000+ 道各类技术面试题在线刷,最新、最全 Java 面试题!

期往精选  点击标题可跳转

【298期】美团一面、阿里一面复盘总结

【299期】Spring Boot 异常处理,值得学习!

【300期】Spring Boot 关于日期时间格式化处理方式总结

【301期】SpringBoot 一个接口同时支持 form、form-data、json 优雅写法

【302期】SpringBoot 项目鉴权的 4 种方式,你了解吗?

【303期】SpringBoot + Redis 实现搜索栏热搜、不雅文字过滤功能

【304期】全文搜索引擎 Elasticsearch,这篇文章给讲透了!

【305期】Spring Cloud 优雅下线+灰度发布

 技术交流群!

最近有很多人问,有没有读者交流群!想知道如何加入?方式很简单,兴趣相投的朋友,只需要点击下方卡片,回复“加群”,即可无套路入交流群!

文章有帮助的话,在看,转发吧!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存