在上上篇博客通過對aspnetcore啟動前配置做了一些更改,以及對nlog進行了自定義字段,可以把請求記錄輸送到mysql,正式情況可能不會這么部署。因為近期也在學習elk,所以就打算做一個實例,結合nlog把日志輸送到logstash,當然現在有開源的.netcore性能監控系統,但是本文的重點是nlog的拓展以及如何拓展。
現有的工作方式
在nlog中,我們在配置文件targets節點下添加一個target就可以定義一個日志輸出目標,當我們需要把日志輸送到logstash時,需要添加一個target節點,其type為Network,填上address,layout等等,一般有如下配置
<targets> <target xsi:type="Network" name="logstash_apiinsight" keepConnection="false" layout="${customer-ip} ${customer-method} ${customer-path} ${customer-bytes} ${customer-duration}" address ="tcp://192.168.93.135:8102" > </target> </targets> <rules> <logger name="WebApp.*" minlevel="Trace" writeTo="logstash_apiinsight" /> </rules>
我們可以在layout中定義很多個字段,然后在當logstash接受到數據包時通過grok來依次解析每一個字段,如果對正則比較熟悉這種方式確實能夠工作,但是當日志記錄的字段數越來越多時,其實是很麻煩的。我個人比較喜歡json,日志通過json發送時,數據更加語義化,在logstash的處理也會容易很多,組織成json發送有什么缺點嗎?現在能想到的只有它會多發送屬性的名稱從而浪費一些資源!因為如果按上面配置的layout,日志的傳輸過程中是不會發送 message,date,level 這些屬性的名稱的,在logstash做稍作處理就可以解析這些字段。要注意的是,上面的 layout中聲明的字段是不存在的,為了方便測試我們可以直接填數據
layout="127.0.0.1 GET /home/index 2000 50"
此時只要你在WebApp命名空間下記錄日志,輸送到logstash的始終都是這一行內容
如果您是通過rpm來安裝的logstash,那么在 /etc/logstash/conf.d 下新建一個 test.conf 輸入下面的內容,同時打開服務器的 8012端口
input { tcp { port => 8102 } } filter { grok { match => { "message" => "%{IP:client} %{WORD:method} %{URIPATHPARAM:request} %{NUMBER:bytes} %{NUMBER:duration}" } } } output { elasticsearch { hosts => "localhost:9200" index => "sample" } }
啟動.netcore程序,多記幾次日志,觀察kibana就會有如下輸出
這里雖然是假數據但是想要真的也很簡單,在我的上上一片博客中就多次使用了NLog中LayoutRenderer這個父類來自定義字段
[LayoutRenderer("customer-ip")] public class ProtocolApiInsightRenderer : LayoutRenderer { protected override void Append(StringBuilder builder, LogEventInfo logEvent) { builder.Append("127.0.0.1"); } }
現有方式有一些問題是很難解決的。如果日志本身有空格呢?我們該尋找哪個字符作為分隔符?再比如當部分字段可空部分字段不可空的時候,當要傳三個數字到logstash,結果只傳了兩個,那grok該怎么解析呢,雖然不一定會遇到這種情況,但也反映了語義化的json是更容易處理的。
拓展NLog
思考的過程
其實到這里我們不難發當我們需要向服務器發送tcp數據包時現現有的工作方式是存在不足的,准確來說是向logstash發送數據包是存在不足的,因為這里本身就是一個 NLog.Targets.NetworkTarget,它的原本目標就是向TCP發送日志,在使用nlog的過程中我們知道在向數據庫服務器發送日志的時候可以通過配置文件中的parameter節點表明字段,但是NetworkTarget是不支持這樣的方式的
<target name = "db_log" xsi:type="Database" dbProvider="MySql.Data.MySqlClient.MySqlConnection, MySql.Data" connectionString="${var:connectionString}" > <commandText> insert into log( application, logged, level, message, logger, callsite, exception, ip, user, servername, url ) values( @application, @logged, @level, @message, @logger, @callsite, @exception, @ip, @user, @servername, @url ); </commandText> <parameter name = "@application" layout="${apiinsight-application}" /> <parameter name = "@logged" layout="${date}" /> <parameter name = "@level" layout="${level}" /> <parameter name = "@message" layout="${message}" /> <parameter name = "@logger" layout="${logger}" /> <parameter name = "@callSite" layout="${callsite:filename=true}" /> <parameter name = "@exception" layout="${apiinsight-request-exception}" /> <parameter name = "@IP" layout="${aspnet-Request-IP}"/> <parameter name = "@User" layout="${apiinsight-request-user}"/> <parameter name = "@serverName" layout="${apiinsight-request-servername}" /> <parameter name = "@url" layout="${apiinsight-request-url}" /> </target>
通過觀察源碼還可以發現這里所有的type都是通過繼承Target這個抽象類來定義的,所以現在很容易想到一種方式來拓展,那就是寫一個 LogstashTarget 繼承Target,但是你會發現這要要做的東西非常多,並且可能需要進一步的閱讀NLog的源碼,NLog代碼量相對其他框架來說以及很少了,但是至少還要做這些工作
1、添加類似parameter的節點
2、TCP連接池和消息發送隊列
雖然這兩項都可以借鑒 NetworkTarget 和 DatabaseTarget 他們的工作方式,但是很明顯這樣的代碼(在你不修改源碼的情況下)你會寫兩遍,軟件設計的基本原則就是盡量拓展少修改。
所以我們很快會想到第二種方法,既然要結合NetworkTarget 和 DatabaseTarget他們兩的特點,那我是否可以寫一個LogstashTarget繼承自NetworkTarget,就避免了了 TCP 連接池和消息發送隊列的重復代碼,柑橘哪不對!因為還要重復DatabaseTarget的一部分代碼!這里是我的思考過程,其實最主要的原因還是想少些一些代碼吧,所以我考慮到了第三種方法
拓展不行,將就用着
類型LayoutRenderer從出現到現在我一直都是認為它就是用來自定義字段的,但是觀察源碼就會發現它的子類非常多,並沒有去研究每一個子類都做了什么,但是現在他可以最快的幫助我實現想要的功能。這里我定義了一個抽象類
/// <summary> /// 由於 <see cref="NLog.Targets.NetworkTarget"/> 沒有提供 parameter 字段 /// 為了更好的把數據組織到 logstash,我們可以在這里自定義字段最終以 json 傳輸到 logstash /// </summary> public abstract class LogstashLayoutRenderer : LayoutRenderer { protected HttpContext httpContext => HttpContextProvider.Current; protected async override void Append(StringBuilder builder, LogEventInfo logEvent) { builder.Append(await ProviderJson()); } protected abstract Task<string> ProviderJson(); }
抽象類LogstashLayoutRenderer通過子類實現的方法ProviderJson向builder寫入數據,這種方法最簡單是因為我的根本需求是想給Logstash發一個json格式的日志,所以這樣也比較好理解,至於接下來我想發這個格式的json還是那個格式的json都可以通過實現該類型來達到我的目標,所以現在的方法依然使用NetworkTarget作為輸出目標。 此外這里把 HttpContext放進來似乎有點奇怪,可能當時懶得寫那么長HttpContextProvider.Current這樣去拿吧,可以在這里看它的代碼是怎么實現的 https://www.cnblogs.com/cheesebar/p/9078207.html 。不過這樣的做法還有一個缺點就是不能在配置文件中定義想要的字段
Logstash的字段
/// <summary> /// 給 <see cref="NetworkTarget"/> 優雅的自定義字段 /// </summary> public abstract class LayoutFieldBase { public abstract Task<string> ProviderField(); public abstract string ProviderFieldName { get; } public HttpContext httpContext => HttpContextProvider.Current; }
在給這個類取名字的時候是LogstashFieldBase好呢還是NetworkFieldBase好呢。糾結中就叫LayoutFieldBase吧,其實他是有兩個作用的,一方面LogstashLayoutRenderer的ProviderJson方法會搜集所有的字段組織成json,這些字段都是繼承自該接口的,另一方面如果我不僅要把這個相同的日志記錄到Logstash可能還要記錄到db或者文件。LogstashLayoutRenderer的實現者總是包含很多個LayoutFieldBase,這個是寫死的,同時因為可能還要記錄到db,那我還為每一個LayoutFieldBase的實現者定義了一個LayoutRendererBase。可以看到Append方法調用子類的實現方法來填寫響應的字段值,也就是說子類提供一個LayoutFieldBase就可以避免同樣的代碼寫兩遍
/// <summary> /// 已知的是這里通過 <see cref="LayoutFieldBase"/> 給 <see cref="NetworkTarget"/> 優雅的自定義字段 /// 但是考慮到有些字段可能同事也要輸入到 <see cref="DatabaseTarget"/>,但是相同字段的值獲取方式是一樣的 /// Append 方法通過代理接口 <see cref="ILayoutProxy"/> 提供的 <see cref="LayoutFieldBase"/> 取值 /// </summary> public abstract class LayoutRendererBase : LayoutRenderer, ILayoutProxy { public abstract Type LayoutType { get; } private LayoutFieldBase _layout; public LayoutFieldBase Layout { get { if (_layout == null) { if (HttpContextProvider.Current != null) { _layout = HttpContextProvider.Current.RequestServices.GetServices<LayoutFieldBase>().First(t => t.GetType() == LayoutType); } } return _layout; } } protected async override void Append(StringBuilder builder, LogEventInfo logEvent) { builder.Append(await Layout?.ProviderField()); } }
當輸出目標非Network的時候,依然可以通LayoutRendererBase的實現者的LayoutRenderer特性在配置文件中應用它,這里看一個例子
[LayoutRenderer("apiinsight-application")] public class ApplicationApiInsightRenderer : LayoutRendererBase { public override Type LayoutType => typeof(AppLayout); }
預先定義了一些LayoutFieldBase和LayoutRendererBase
真正進行字段值計算的是左邊的這些類型,右邊的這些類型通過代理使用左邊的類型來提供字段,所有的LayoutFieldBase都在開始時注入到容器
/// <summary> /// 應用程序名稱 /// </summary> public class AppLayout : LayoutFieldBase { public override string ProviderFieldName => "app"; public async override Task<string> ProviderField() { if (httpContext != null) { var env = httpContext.RequestServices?.GetService<IHostingEnvironment>(); return env.ApplicationName; } return string.Empty; } } [LayoutRenderer("apiinsight-application")] public class ApplicationApiInsightRenderer : LayoutRendererBase { public override Type LayoutType => typeof(AppLayout); }
/// <summary> /// 配合 NLog (Target Network) 注入自定義字段 /// 自定義字段都繼承自 <see cref="LayoutFieldBase"/> /// </summary> public static class LogstashLayoutBaseServiceCollectionExtensions { public static void AddLayoutBase(this IServiceCollection services) { var layouts = AppDomain.CurrentDomain.GetAssemblies().SelectMany(t => t.GetTypes()) .Where(t => typeof(LayoutFieldBase).IsAssignableFrom(t) && !t.IsAbstract); foreach (var item in layouts) { services.AddSingleton(typeof(LayoutFieldBase), item); } } }
拓展完成
對於拓展這件事,其實已經做完了,因為接下來的事情是業務相關的,在回想一下通過自定義的LogstashLayoutRenderer組織Json到Logstash。在拓展中已經定義了一些可能用到的字段比如說應用程序名稱AppLayout,請求方法MethodLayout等等
asp.netcore接口請求統計
新增的start和time字段
回顧之前我寫的博客發現只有請求開始時間和請求消耗時間沒有在之前的拓展寫進來,所在在這里加進來
/// <summary> /// 請求到達的時間 /// </summary> public class StartLayout : LayoutFieldBase { public override string ProviderFieldName => "start"; public async override Task<string> ProviderField() { if (httpContext != null) { var _apiInsightsKeys = httpContext.RequestServices.GetService<IApiInsightsKeys>(); if (httpContext != null) { if (httpContext.Items.TryGetValue(_apiInsightsKeys.StartTimeName, out var start) == true) { return ((DateTime)start).ToString("yyyy/MM/dd hh:mm:ss"); } } } return string.Empty; } } /// <summary> /// 請求消耗的時間 /// </summary> public class TimeLayout : LayoutFieldBase { public override string ProviderFieldName => "interval"; public async override Task<string> ProviderField() { if (httpContext != null) { var _apiInsightsKeys = httpContext.RequestServices.GetService<IApiInsightsKeys>(); if (httpContext != null) { if (httpContext.Items.TryGetValue(_apiInsightsKeys.StopWatchName, out var stopWatch) == true) { return (stopWatch as Stopwatch).ElapsedMilliseconds.ToString(); } } } return string.Empty; } }
測試的時候可能我也要看統計有沒有成功記錄,需要對比數據庫和elk,所以數據庫依然要寫,這里定義相應的LayoutRenderer
[LayoutRenderer("apiinsight-start")] public class StartApiInsightRenderer : LayoutRendererBase { public override Type LayoutType => typeof(StartLayout); } [LayoutRenderer("apiinsight-time")] public class TimeApiInsightRenderer : LayoutRendererBase { public override Type LayoutType => typeof(TimeLayout); }
核心ApiInsightLogstashLayoutRenderer
在調試的時候發現所有的LayoutRenderer都是單例的,所以這邊的Layouts其實都只會創建一次,所以性能會比想象的好很多,json就是通過newtonsoft這個褲來創建的。
/// <summary> /// 在 NLog 配置文件中,Network 我們只需要注冊一個 Layout,名稱就是 logstash-apiinsight /// </summary> [LayoutRenderer("logstash-apiinsight")] public class ApiInsightLogstashLayoutRenderer : LogstashLayoutRenderer { static readonly Type[] LayoutTypes = new[] { typeof(StartLayout), typeof(TimeLayout), typeof(ProtocolLayout), typeof(HostLayout), typeof(PortLayout), typeof(PathLayout), typeof(QueryLayout), typeof(ClientIPLayout), typeof(ServerIPLayout), typeof(AuthLayout), typeof(HttpStatusLayout), typeof(AppLayout), typeof(MethodLayout), }; static LayoutFieldBase[] Layouts; void Init(IServiceProvider serviceProvider) { var services = serviceProvider.GetServices<LayoutFieldBase>(); Layouts = services.Where(t => LayoutTypes.Contains(t.GetType())).ToArray(); if (Layouts.Length != LayoutTypes.Length) { throw new Exception(nameof(ApiInsightLogstashLayoutRenderer) + " 的 Layouts 和預定義數目的不匹配"); } } protected async override Task<string> ProviderJson() { if (Layouts == null) { Init(httpContext.RequestServices); } var dic = new Dictionary<string, string>(); foreach (var item in Layouts) { dic.Add(item.ProviderFieldName, await item.ProviderField()); } var json = JObjectHelper.CreateSimpleJson(dic).Replace(Environment.NewLine, string.Empty); return json; } }
logstash配置
input { tcp { port => 8102 } } filter{ json { source => "message" } date { match => [ "start", "yyyy/MM/dd HH:mm:ss" ] } mutate{ convert => { "statusCode" => "integer" "interval" => "integer" "port" => "integer" } } } output { elasticsearch { hosts => "localhost:9200" index => "core-%{+YYYY.MM.dd}" } }
這里就做了一些字段的類型轉換,因為默認的所有字段都是string類型,是不方便統計的。
nlog配置
<target xsi:type="Network" name="logstash_apiinsight" keepConnection="false" layout="${logstash-apiinsight}" address ="tcp://192.168.93.135:8103" > </target>
小結
因為近兩天公司事情也比較少,事情做完了就亂搗鼓,在使用nlog的Network向Logstash發送數據的時候發現確實不大好用,所以就思考了這樣的一個實現方式,基本的是可以了,但是還有一些功能比如說層級json怎么定義,這其實就是單純的寫代碼,很有意思的一件事,如果您也有想法或者覺得我的代碼有不好的地方或者可以改進的地方,歡迎一起討論。