PleasanterのSysLogsテーブルを閲覧するアプリを ASP.NET Core 5.0 (Razor Pages) で作ってみた

## SysLogsテーブル

Pleasanterで問題が発生した際の原因究明に欠かせないものに、Pleasanaterのあらゆるログ情報を記録しているSysLogsテーブルがあります。
通常は、SQL Server Management Studio（SSMS）など、Databaseのクライアントツールを用いてログの解析を行いますが、もう少し手軽にSysLogsテーブルの情報を閲覧できたらいいな、と思うことがあります。

そこで、SysLogsテーブルにすぐさまアクセス可能なWebアプリケーションを作ってみました。

## SysLogViewer

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/a5565074-d2f0-9228-3998-324c042a98b2.png)

- ASP.NET Core 5.0 (Razor Pages)で作成
- SysLogsテーブルの重要なカラムのみを一覧表示
- 下記の条件で絞り込み表示が可能
  - レコードの作成日付の範囲
  - SyLogType
  - リクエストURL文字列の部分一致

※ひとまず、サンプルレベルの最低限の機能で実装しています。
認証機能なども省略していますので、実際にWeb上に公開して利用する場合は、これらの機能を実装する必要があります。

※また、今回のサンプルはPleasanterのデータベースがSQL Serverで構築されていることを前提としています。

## ソースコード

今回のサンプルコードは、GitHub上で公開しています。
https://github.com/pierre3/SysLogViewer

## ASP.NET Core Webアプリの作成

今回は、この`SysLogsViewer` の開発手順について解説していきたいと思います。

### プロジェクト作成

新しいプロジェクトの作成ダイアログで、「ASP.NET Core Webアプリ」を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/6694841d-c9d8-2d68-abfa-cf8d4614b8fc.png)

プロジェクト名を指定して「次へ」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/a16f4aa0-d3aa-c025-8cd8-8327d75b9867.png)

認証の種類を「なし」にしてプロジェクトを作成します。
（今回はサンプル用のため認証機能を省略していますが、実際にWeb上に公開して使う場合は、各自適切な認証を行うよう実装しましょう。）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/7eba0384-0416-a08e-c953-a2c639ef8661.png)

### Dapperのインストール

データベースから取得したデータをC#のモデルクラスとマッピングするのに Dapper を使います。

- メニュー [ツール]-[NuGet パッケージマネージャーコンソール]-[ソリューションのパッケージの管理]を開き
- 「Dapper」 および SQL Server 用のクライアントライブラリ「System.Data.SqlClient」をインストールします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/61ab6855-97c2-38f3-dfca-3d1b26880cc7.png)


### Modelクラス
データベースから取得したデータをマッピングするためのモデルクラスを定義します。

Implem.Pleasanter.NetCore のソースコードから SysLogModel.cs を参照して、ここからプロパティのみを抜き出したモデルクラスを作成します。

https://github.com/Implem/Implem.Pleasanter.NetCore/blob/master/Implem.Pleasanter/Models/SysLogs/SysLogModel.cs

```csharp
public enum SysLogTypes : int
{
    None =0,
    Info = 10,
    Warning = 50,
    UserError = 60,
    SystemError = 80,
    Execption = 90
}

public class SysLogModel
{
    public DateTime CreatedTime { get; set; }
    public long? SysLogId { get; set; }
    public int? Ver { get; set; }
    public SysLogTypes SysLogType { get; set; }
    public bool OnAzure { get; set; }
    public string MachineName { get; set; }
    public string ServiceName { get; set; }
    public string TenantName { get; set; }
    public string Application { get; set; }
    public string Class { get; set; }
    public string Method { get; set; }
    public string RequestData { get; set; }
    public string HttpMethod { get; set; }
    public int RequestSize { get; set; }
    public int ResponseSize { get; set; }
    public double? Elapsed { get; set; }
    public double? ApplicationAge { get; set; }
    public double? ApplicationRequestInterval { get; set; }
    public double? SessionAge { get; set; }
    public double? SessionRequestInterval { get; set; }
    public long? WorkingSet64 { get; set; }
    public long? VirtualMemorySize64 { get; set; }
    public int? ProcessId { get; set; }
    public string ProcessName { get; set; }
    public int? BasePriority { get; set; }
    public string Url { get; set; }
    public string UrlReferer { get; set; }
    public string UserHostName { get; set; }
    public string UserHostAddress { get; set; }
    public string UserLanguage { get; set; }
    public string UserAgent { get; set; }
    public string SessionGuid { get; set; }
    public string ErrMessage { get; set; }
    public string ErrStackTrace { get; set; }
    public bool? InDebug { get; set; }
    public string AssemblyVersion { get; set; }
    public string Comments { get; set; }
    public int? Creator { get; set; }
    public int? Updator { get; set; }
    public DateTime? UpdatedTime { get; set; }

    public SysLogModel()
    {

    }
}
```

### アプリケーション設定をからデータベースの接続文字列を取得する

まずは、AppSettings.csを作成し、接続文字列とコマンドタイムアウトの設定を受け取れるようにします。

```csharp
public class AppSettings
{
    public string ConnectionString { get; set; }
    public int CommandTimeout { get; set; }
}
```

次に、appSettings.json に上記AppSettingsクラスのエントリを追加します。

```json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "AppSettings": {
    "ConnectionString": "Data Source=(local);Initial Catalog=Implem.Pleasanter;User ID=sa;Password=SetSaPWD",
    "CommandTimeOut": 1800
  }
}
```

そして、Startup.cs で Configuration から AppSettings のデータを取得して DIコンテナに登録したら準備完了です。

```csharp
public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();
    services.Configure<AppSettings>(Configuration.GetSection(nameof(AppSettings)));
}
```

### IndexModelクラスの実装

Webアプリプロジェクトのテンプレートから生成された Index.cshtml 及び Index.cshtml.cs ファイルを書き換えてトップページにSysLogsテーブルの一覧が表示されるようにします。

Index.cshtml.cs ファイルを開くと、`IndexModel` クラスが定義されています。
このクラスのコンストラクタで DIコンテナに登録した AppSettings を受け取るようにします。

ブラウザでトップページにアクセスすると、`OnGetAsync()`（または`OnGet()`）メソッドが呼ばれます。
ここにデータベースから SysLogs テーブルのデータを取得する処理を記述します。
取得したデータは `BindProperty`属性を付けた `SysLogs` プロパティに格納しておきます。

```csharp
public class IndexModel : PageModel
{
    private readonly ILogger<IndexModel> _logger;
    private readonly IOptions<AppSettings> _appSettings;

    [BindProperty]
    public IList<SysLogModel> SysLogs { get; set; }

    public IndexModel(ILogger<IndexModel> logger, IOptions<AppSettings> appSettings)
    {
        _logger = logger;
        _appSettings = appSettings;
    }

    public async Task OnGetAsync()
    {
        SysLogs = await SysLogsテーブルからデータ取得する処理();
    }
}
```

### PleasanterDbClient クラス

それでは、PleasanterのデータベースにアクセスしてSysLogsテーブルのデータを取得する処理を実装します。

1. コンストラクタでは下記の処理を行います。
    - 引数で渡ってきた接続文字列を基に `SqlConnection` クラスを生成します。
    - `SqlConnection` の`Open()` メソッドを呼び出し、接続を開きます。
2. GetSysLogsメソッドを定義し、SysLogsテーブルのレコードを取得するクエリを実行します。
    - Dapperの拡張メソッド `QueryAsync` でSQLを実行し、結果を `SysLogModel`クラスのコレクションとして受け取ります。
    - ここではひとまず、先頭1000レコードを条件なしで取得します。

```csharp
public class PleasanterDbClient : IDisposable
{
    private DbConnection Connection { get; set; }
    private int CommandTimeout { get; set; }

    public PleasanterDbClient(string connectionString, int commandTimeout)
    {
        Connection = new SqlConnection(connectionString);
        Connection.Open();
        CommandTimeout = commandTimeout;
    }

    public async ValueTask<IEnumerable<SysLogModel>> GetSysLogs()
    {
        return await Connection.QueryAsync<SysLogModel>(
            "select top (1000) * from [SysLogs]", 
            commandTimeout: CommandTimeout);
    }

    public void Dispose()
    {
        Connection.Close();
        Connection.Dispose();
    }
}
```

### Viewの記述

Index.cshtml を以下のように書き換えます。

IndexModelで定義したSysLogsプロパティには `Model.SysLogs` でアクセス可能です。

```csharp
@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<table class="table table-striped table-hover">
    <thead class="thead-dark">
        <tr>
            <th>CreatedTime</th>
            <th>SysLogId</th>
            <th>SysLogType</th>
            <th>Class</th>
            <th>Method</th>
            <th>HttpMethod</th>
            <th>Elapsed</th>
            <th>Url</th>
            <th>ErrMessage</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.SysLogs)
        {
            <tr>
                <td>@item.CreatedTime</td>
                <td>@item.SysLogId</td>
                <td>@item.SysLogType</td>
                <td>@item.Class</td>
                <td>@item.Method</td>
                <td>@item.HttpMethod</td>
                <td>@item.Elapsed</td>
                <td>@item.Url</td>
                <td>@item.ErrMessage</td>
            </tr>
        }
    </tbody>
</table>
```

ひとまず、利用頻度の高そうな下記項目のみを一覧表示するようにします。

- CreatedTime (登録日時）
- SysLogId（ログのID）
- SysLogType（ログタイプ  Info,Warning,UserError,SystemError,Execption）
- Class (PleasanterのControllerのクラス名 Items,Users,Deptsなど)
- Method (PleasanterのControllerのメソッド（アクション）名 Index,Edit,Updateなど）
- HttpMethod（Http メソッド Get,Post,Putなど）
- Elapsed (サーバー側でリクエストの処理に要した時間(ms))
- Url (リクエストURL)
- ErrMessage (サーバー側で予期しないエラーが発生した場合のエラーメッセージ。SySLogTypeがExceptionの場合に記録される)

ここまでで、とりあえず上位1000件のSysLogs一覧を表示することができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/de8b1210-5e3e-407e-a93f-1dc51030f607.png)


###　検索機能を追加

一覧をそのまま表示するだけでは、目的のログを探すことは困難です。
そこで、必要最低限の検索機能を実装したいと思います。

一覧画面に、下記のフォームを追加します。

- CreatedTimeで絞るための日付範囲 「StartDate」 - 「EndDate」
- SysLogTypeで絞るためのドロップダウンリスト 「SysLogType」
- URLの文字列を部分一致検索するためのテキストボックス「Url」

#### フォームにバインドするプロパティの定義

まずは、`IndexModel`に、フォームの入力項目に対応するプロパティを設定します。

```cs
public class IndexModel : PageModel
{
    //(省略)

    [BindProperty]
    public IList<SysLogModel> SysLogs { get; set; }

    [BindProperty]
    public DateTime? StartDate { get; set; }

    [BindProperty]
    public DateTime? EndDate { get; set; }

    [BindProperty]
    public SysLogTypes SysLogType { get; set; }
    
    [BindProperty]
    public string UrlString { get; set; }
    
    //省略
}
```
#### Viewに検索フォームを定義

次に、Index.cshtml に検索フォームを追加します。
各コントロールの `asp-for` 属性に`IndexModel`で定義したプロパティ名を設定するだけで、サーバー側とのデータのやり取りが定義できます。

```cs
<div class="filter">
    <form method="post">
        <div class="form-row">
            <div class="col-2">
                <label asp-for="StartDate" class="form-label"></label>
                <input type="datetime-local" class="form-control" asp-for="StartDate" placeholder="開始日時">
            </div>
            <div class="col-2">
                <label asp-for="EndDate" class="form-label"></label>
                <input type="datetime-local" class="form-control" asp-for="EndDate" placeholder="終了日時">
            </div>
            <div class="col-2">
                <label asp-for="SysLogType" class="form-label"></label>
                <select class="form-control" asp-for="SysLogType" asp-items="Html.GetEnumSelectList<Models.SysLogTypes>()"></select>
            </div>
            <div class="col-4">
                <label asp-for="UrlString" class="form-label"></label>
                <input class="form-control" asp-for="UrlString" />
            </div>
            <div class="col-2 align-self-end">
                <input class="btn btn-primary" type="submit" value="検索" />
            </div>
        </div>
    </form>
</div>
```

#### フォームデータのPostメソッドを受け付けるメソッドを追加

さらに、`IndexModel`に、OnPostAsyncメソッドを追加して、フォームのSubmitボタンがクリックされた際の処理を記述します。

```csharp
public class IndexModel : PageModel
{
    //(省略)

   public async Task OnPostAsync()
   {
       using var db = new PleasanterDbClient(_appSettings.Value.ConnectionString, _appSettings.Value.CommandTimeout);
       //GetSysLogsメソッドに検索条件を渡してSQLを実行。取得結果をSysLogsプロパティに格納する
       var items = await db.GetSysLogs(StartDate, EndDate, SysLogType, UrlString);
       SysLogs = items.ToList();
   }    
}
```

#### データ取得処理に検索条件を追加

最後に、`PleasanterDbClient` クラスの `GetSysLogs()` メソッドを検索に対応したバージョンに書き換えます。

````cs
public async ValueTask<IEnumerable<SysLogModel>> GetSysLogs(
    DateTime? startDate,DateTime? endDate, SysLogTypes sysLogType, string url)
{
    var whereList = new List<string>(); //各検索項目毎のwhere句を格納
    var param = new DynamicParameters(); //where句で利用するパラメータを格納
    //StartDateのwhere句追加
    if (startDate is not null) 
    {
        whereList.Add($"[CreatedTime] > @startDate");
        param.Add("startDate", startDate);
    } 
    //EndDateのwhere句追加
    if(endDate is not null)
    {
        whereList.Add($"[CreatedTime] < @endDate");
        param.Add("endDate", endDate);
    }
    //SysLogTypeのwhere句追加
    if(sysLogType != SysLogTypes.None)
    {
        whereList.Add($"[SysLogType] = @sysLogType");
        param.Add("sysLogType", (int)sysLogType);
    }
    //Urlのwhere句追加
    if (!string.IsNullOrEmpty(url))
    {
        whereList.Add($"[Url] like @url");
        param.Add("url", "%" + url + "%");
    }
    //where句を"and"でつなげる
    var where = (whereList.Count > 0) ? "where " + string.Join(" and ", whereList) : string.Empty;
    
    //SQL文にwhere句を追加して実行
    return await Connection.QueryAsync<SysLogModel>(
        $"select top (1000) * from [SysLogs] {where}", 
        param, 
        commandTimeout: CommandTimeout);
}
````

これで、検索機能の実装も完了です。

#### 実行例

- 11/26のログのみを表示

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/2391014c-5284-0c16-4d11-b40af448127d.png)

- アプリケーションエラーのログのみ表示

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/cf8292be-00a6-73cb-47ec-c12f61ff5cb6.png)

- サイトID 100000420のアクセスログを表示

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28805/9647cff9-1eab-4a27-f5cb-adc36f30a0db.png)

## まとめ

筆者自身、今回初めてASP.NET Core Reazor Pages でWebアプリケーションを作成してみましたが、従来のMVCアプリよりも簡易にかつ直観的に実装できたように感じました。
また、今回作成したSysLogViwerですが、表示項目や検索条件をカスタマイズすれば、より実用的なログ解析アプリとして活用できると思います。
気になった方がいらっしゃいましたら、ソースコードをフォークして、カスタマイズしてみてはいかがでしょうか。