0%

[DotnetCore]NLog-ILoggerManager設計

前提情要

筆者有幸參與到科內負責的大系統,不能單靠一個API站台就解決所有的需求,必須搭配SchedulerWindowsService等服務,介接技術上,可能會用Socket或是HttpClient介接,再加上自己系統本身,分散多個專案,中間使用Message Queue技術串聯起來,算是一個滿有挑戰性的系統了,挑戰主要是在串連服務的難度上,而非高流量。因此追蹤問題上,需要靠Loggin機制,由於各式各樣的服務,不能單靠底層Logging套件:NLog預設的紀錄欄位,針對不同種類的服務,做一個客製化屬性欄位的擴充才行。

內容

回到此篇主題,為什麼會有ILoggerManager需求,依照前提情要中提到的情境,可以將大系統中的服務類型大致上可以分為API站台、Scheduler排程服務,Socket服務這三大類,試想可紀錄或紀錄關心的欄位有所不同。

以API站台來說,關心登入者與API Path,這樣才能快速找到對應的程式,並修復;Scheduler排程服務來說,關心的是Scheduler服務名稱,以此類推,依照需求面向不同,需要設計出不一樣的LoggerManager

以上述分類,筆者設計出三種不同的LoggerManager,分別為:

  • APILoggerManager
  • SchedulerLoggerManager
  • ExchangeLoggerManager

ILoggerManager

跟著筆者定義一下LoggerManager

1
2
3
4
5
6
7
public interface ILoggerManager
{
void LogInfo(string message);
void LogWarn(string message);
void LogDebug(string message);
void LogError(string message);
}

APILoggerManager

NLog宣告語法

主要新增「UserId」、「APIPath」

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CREATE TABLE [dbo].[NLog](
[ID] [int] IDENTITY(1,1) NOT NULL,
[MachineName] [nvarchar](200) NULL,
[UserId] [varchar](10) NULL,
[APIPath] [varchar](400) NULL,
[Logged] [datetime] NOT NULL,
[Level] [varchar](5) NOT NULL,
[Message] [nvarchar](max) NOT NULL,
[Logger] [nvarchar](300) NULL,
[Properties] [nvarchar](max) NULL,
[Callsite] [nvarchar](300) NULL,
[Exception] [nvarchar](max) NULL,
CONSTRAINT [PK_dbo.Log] PRIMARY KEY CLUSTERED
(
[ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]

APILoggerManager實作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/// <summary>
/// API所需LoggerManager
/// </summary>
public class APILoggerManager : ILoggerManager
{
private static readonly NLog.Logger _logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IIdentityService _identityService;
private string _apiUrl;

public APILoggerManager(IIdentityService identityService, IHttpContextAccessor context)
{
_identityService = identityService;
_apiUrl = context?.HttpContext?.Request?.Path;
//$"{context.HttpContext.Request.RouteValues["controller"]}/{context.HttpContext.Request.RouteValues["action"]}";
}

public void LogDebug(string message)
{
_logger.WithProperty("UserId", _identityService.GetUserName())
.WithProperty("APIPath", _apiUrl)
.Debug(message);
}

public void LogError(string message)
{
_logger.WithProperty("UserId", _identityService.GetUserName())
.WithProperty("APIPath", _apiUrl)
.Error(message);
}

public void LogInfo(string message)
{
_logger.WithProperty("UserId", _identityService.GetUserName())
.WithProperty("APIPath", _apiUrl)
.Info(message);
}

public void LogWarn(string message)
{
_logger.WithProperty("UserId", _identityService.GetUserName())
.WithProperty("APIPath", _apiUrl)
.Warn(message);
}
}

上述程式碼內容主要是使用IIdentityService取得登入者相關資訊,NLog部份,主要使用WithProperty來設定Custom Column對應的值。來看一下IIdentityService的內容吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public interface IIdentityService
{
int GetUserId();
string GetUserName();
}

public class IdentityService : IIdentityService
{
private readonly IHttpContextAccessor _context;
public IdentityService(IHttpContextAccessor context)
{
_context = context;
}

public int GetUserId()
{
var nameId = _context?.HttpContext?.User.FindFirst("id");

return nameId != null ? Convert.ToInt32(nameId.Value) : 0;
}

public string GetUserName()
{
return _context?.HttpContext?.User.FindFirst("name")?.Value;
}
}

上述程式碼中,主要使用注入IHttpContextAccessor,並取得登入者相關資訊,筆者使用的是最簡單的jwt token的方式驗證登入者的合法性,後端在登入驗證成功後發出一條jwt token,前端在每一個Request的header中帶入拿到的jwt token,達到Authentitcation的效果。因為關係到怎麼塞入Claim資訊,就得用對應的key值做取得登入資訊。

NLog Config宣告

  • Target設定

於NLog.config中宣告database紀錄相關設定,targets中加入database target

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<target name="database" xsi:type="Database">
<connectionString>${var:connectionStringNlog}</connectionString>
<commandText>
insert into dbo.NLog (
MachineName, UserId, APIPath, Logged, Level, Message,
Logger, Callsite, Exception
) values (
@MachineName, @UserId, @APIPath, @Logged, @Level, @Message,
@Logger, @Callsite, @Exception
);
</commandText>

<parameter name="@MachineName" layout="${machinename}" />
<parameter name="@UserId" layout="${event-properties:UserId}" />
<parameter name="@APIPath" layout="${event-properties:APIPath}" />
<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}" />
<parameter name="@Exception" layout="${exception:tostring}" />
</target>

主要值得注意的是${event-properties:UserId}${event-properties:APIPath},這邊的key值要對應到WithProperty中的key值。

  • Rules中套用Target設定

rules區塊中加入database並且設定level為Error

1
<logger name="*" minlevel="Warn" writeTo="database" />

最後要提醒一下,因為APILoggerManager中使用到IIDentityService,IDentityService中又使用到IHttpContextAccessor,因此需於Startup中注入其HttpContextAccessor。

1
2
3
4
5
6
7
8
9
10
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
//以上省略
services.AddHttpContextAccessor();
//以下省略
}
}

SchedulerLoggerManager

NLogScheduler宣告語法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CREATE TABLE [dbo].[NLogScheduler](
[ID] [bigint] IDENTITY(1,1) NOT NULL,
[JobID] [varchar](200) NULL,
[MachineName] [nvarchar](200) NULL,
[Logged] [datetime] NOT NULL,
[Level] [varchar](5) NOT NULL,
[Message] [nvarchar](max) NOT NULL,
[Logger] [nvarchar](300) NULL,
[Properties] [nvarchar](max) NULL,
[Callsite] [nvarchar](300) NULL,
[Exception] [nvarchar](max) NULL,
CONSTRAINT [PK_NLogScheduler] PRIMARY KEY CLUSTERED
(
[ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'排程ID' , @level0type=N'SCHEMA',@level0name=N'dbo', @level1type=N'TABLE',@level1name=N'NLogScheduler', @level2type=N'COLUMN',@level2name=N'JobID'
GO

SchedulerLoggerManager實作

筆者這邊環境是使用Hangfire實作相關排程程式,所以會有一些針對Hangfire做的一些客製化作業,先看一下SchedulerLoggerManager實作程式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class SchedulerLoggerManager : ILoggerManager
{
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();

public void LogDebug(string message)
{
_logger.WithProperty("JobID", JobContext.JobId)
.Debug(message);
}

public void LogError(string message)
{
_logger.WithProperty("JobID", JobContext.JobId)
.Error(message);
}

public void LogInfo(string message)
{
_logger.WithProperty("JobID", JobContext.JobId)
.Info(message);
}

public void LogWarn(string message)
{
_logger.WithProperty("JobID", JobContext.JobId)
.Warn(message);
}
}

以上程式碼中所使用到的JobContext.JobId則是筆者另外寫的一個可以取得當次SchedulerJobId名稱,不過筆者這邊是有導入動態排程機制,由資料庫去控制其排程設定值,可以透過JobInitial程式可以重新宣告排程,因此取得JobId部份非常地客製化,參考其結構就可以。

IServerFilter實作

筆者這邊使用的是Hangfire有開放的IServerFilter實作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class JobContext : IServerFilter
{
[ThreadStatic]
private static string _jobId;

public static string JobId { get { return _jobId; } set { _jobId = value; } }

public void OnPerformed(PerformedContext filterContext)
{

}

public void OnPerforming(PerformingContext context)
{
// 取得context中的某個值當作Logger機制中的識別JobId
}
}

IServerFilter註冊

這個IServerFilter實作需要註冊,須於Startup中註冊才會生效

1
2
3
4
5
6
7
8
9
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
//以上省略
GlobalConfiguration.Configuration.UseFilter(new JobContext());
//以下省略
}
}

NLog.Config部份參考其APILoggerManager說明,只要將APILoggerManager客製化屬性去除,新增SchedulerLoggerManager客製化屬性即可。

ExchangeLoggerManager

該LoggerManager主要運用於Socket相關專案中,筆者這邊負責與五個系統使用Socket串接,對筆者來說主要是可以識別為哪個系統對應的專案log即可,因此只要多加一個欄位為「SystemId」,方便筆者去做一個Filter。

NLogExchange宣告語法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CREATE TABLE [dbo].[NLogExchange](
[ID] [bigint] IDENTITY(1,1) NOT NULL,
[SystemId] [varchar](20) NULL,
[MachineName] [nvarchar](200) NULL,
[Logged] [datetime] NOT NULL,
[Level] [varchar](5) NOT NULL,
[Message] [nvarchar](max) NOT NULL,
[Logger] [nvarchar](300) NULL,
[Properties] [nvarchar](max) NULL,
[Callsite] [nvarchar](300) NULL,
[Exception] [nvarchar](max) NULL,
CONSTRAINT [PK_NLogExchange] PRIMARY KEY CLUSTERED
(
[ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

ExchangeLoggerManager實作

主要是使用筆者設計下的Options參數取得系統名稱,筆者有包裝成一個AddSocketServiceExtension,由外層Client使用時方便的使用,但也多個系統共用,需要使用Options參數來控制其對應的設定值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ExchangeLoggerManager : ILoggerManager
{
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
private SocketSettingOption _socketSettingOption;

public ExchangeLoggerManager(IOptions<SocketSettingOption> options)
{
_socketSettingOption = options.Value;
}

public void LogDebug(string message)
{
_logger.WithProperty("SystemId", _socketSettingOption.AppId)
.Debug(message);
}

public void LogError(string message)
{
_logger.WithProperty("SystemId", _socketSettingOption.AppId)
.Error(message);
}

public void LogInfo(string message)
{
_logger.WithProperty("SystemId", _socketSettingOption.AppId)
.Info(message);
}

public void LogWarn(string message)
{
_logger.WithProperty("SystemId", _socketSettingOption.AppId)
.Warn(message);
}
}

筆者在另一個Socket文章系列中有提及其設計原理,可能要讀者對照著參考,才會理解其中的意義,不過至少在這邊列一下SocketSettinOption物件,詳細說明參考其對應文章:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SocketSettingOption
{
public string MainSocketSetting { get; set; }
public string SubSocketSetting { get; set; }
public string HubUrlSetting { get; set; }

public Func<List<byte>, Tuple<List<byte>, List<Byte[]>>> ClientSliceByteFunc { get; set; }
public Func<List<byte>, Tuple<List<byte>, List<Byte[]>>> ServerSliceByteFunc { get; set; }

public bool DisconnectAfterClientReceive { get; set; } = false;

public string AppId { get; set; }
public string AppName { get; set; }
public string ServiceName { get; set; }
public bool MonitorService { get; set; } = true;

#region ---ForLogging---
public Func<byte[], Type> GetHeaderResponseModelType { get; set; }
= (data) => { return typeof(HeaderResponseModel); };
public string ConnectionStringKey { get; set; } = "FMTR";
#endregion
}

NLog.Config部份參考其APILoggerManager說明,只要將APILoggerManager客製化屬性去除,新增SchedulerLoggerManager客製化屬性即可。

Client套用

注入對應的ILoggerManager實作

1
2
3
4
5
6
7
8
9
10
11
12
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
// 以上省略
// 切換成自己的連線字串
LogManager.Configuration.Variables["connectionStringNlog"] = "{connectionString}";
// 換成自己要的ILoggerManager實作Service
services.AddTransient<ILoggerManager, APILoggerManager>();
// 以下省略
}
}

Client的Service端中實際使用

主要是使用建構值注入的方式取得其ILoggerManager實體

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface IFlowService
{
void DoProcess(FlowConditions conditions);
}

public class FlowService: IFlowService
{
private readonly ILoggerManager _logger;
public FlowService(ILoggerManager logger)
{
_logger = logger;
}

public void DoProcess(FlowConditions conditions)
{
_logger.LogInfo("Flow do process start!");
// business logic
_logger.LogInfo("Flow do process end!");
}
}

結論

筆者透過將不同需求分類的方式將NLog做一個有效的分類,Log資料量分開之外,查問題時更清楚自己要查的資料,可以有效的查詢問題,藉由設計客製化欄位,對於NLog掌握度更高了。