springCloud微服務系列——分散式日誌採集
目錄
一、簡介
分散式應用必須有一套日誌採集功能,目的是將分佈在各個伺服器節點上的應用日誌檔案採集到統一的伺服器上,方便日誌的檢視。springCloud本身提供了基於elk的日誌採集,但是由於使用logstash,會加大運維成本。這裡將使用輕量級的方案。
二、思路
我們的目的是提供輕量級的日誌採集來代替logstash,日誌最終還是會存進Elasticsearch。為了能輕量級的實現日誌採集,並且避免對程式碼的侵入,我們可以擴充套件Logback的appender,也可以擴充套件log4j的appender。這樣我們使用slf4j來記錄日誌的時候,日誌自動會儲存到Elasticsearch中,並且不用修改任何業務程式碼。
三、自定義Logback appender
我們先來看一下Logback的appender的Uml圖,我們可以發現兩個對我們有借鑑意義的類
UnsynchronizedAppenderBase提供了非同步的日誌記錄
DBAppender基於資料庫的日誌記錄
這兩個類還是比較簡單的,具體的程式碼我就不詳細解說了,請自行查閱
屬性注入
基本實現邏輯從UnsynchronizedAppenderBase和DBAppender已經能夠知道了,現在把我們需要的資訊注入到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);
獲取屬性值可以通過LoggingEvent的getMDCPropertyMap方法先獲取屬性的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
}
}