[鐵人賽 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

[鐵人賽 Day13] ASP.NET Core 2 系列 – Web API 文件產生器 (Swagger)

Swagger 也算是行之有年的 API 文件產生器,只要在 API 上使用 C# 的 <summary /> 文件註解標籤,就可以產生精美的線上文件,並且對 RESTful API 有良好的支援。不僅支援產生文件,還支援模擬調用的互動功能,連 Postman 都不用打開就能測 API。
本篇將介紹如何透過 Swagger 產生 ASP.NET Core 的 RESTful API 文件。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day13] ASP.NET Core 2 系列 – Web API 文件產生器 (Swagger)

安裝套件

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

1
dotnet add package Swashbuckle.AspNetCore

註冊 Swagger

在 Startup.cs 的 ConfigureServices 加入 Swagger 的服務及 Middleware。如下:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
using Swashbuckle.AspNetCore.Swagger;
// ...
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc()
                .AddJsonOptions(options => {
                    options.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
                });

        services.AddSwaggerGen(c =>
        {
            c.SwaggerDoc(
                // name: 攸關 SwaggerDocument 的 URL 位置。
                name: "v1", 
                // info: 是用於 SwaggerDocument 版本資訊的顯示(內容非必填)。
                info: new Info
                {
                    Title = "RESTful API",
                    Version = "1.0.0",
                    Description = "This is ASP.NET Core RESTful API Sample.",
                    TermsOfService = "None",
                    Contact = new Contact { 
                        Name = "John Wu", 
                        Url = "https://blog.johnwu.cc" 
                    },
                    License = new License { 
                        Name = "CC BY-NC-SA 4.0", 
                        Url = "https://creativecommons.org/licenses/by-nc-sa/4.0/" 
                    }
                }
            );
        });
    }
    
    public void Configure(IApplicationBuilder app)
    {
        app.UseSwagger();
        app.UseSwaggerUI(c =>
        {
            c.SwaggerEndpoint(
                // url: 需配合 SwaggerDoc 的 name。 "/swagger/{SwaggerDoc name}/swagger.json"
                url: "/swagger/v1/swagger.json", 
                // description: 用於 Swagger UI 右上角選擇不同版本的 SwaggerDocument 顯示名稱使用。
                description: "RESTful API v1.0.0"
            );
        });

        app.UseMvc();
    }
}
  • AddSwaggerGen
    Swagger 產生器是負責取得 API 的規格並產生 SwaggerDocument 物件。
  • UseSwagger
    Swagger Middleware 負責路由,提供 SwaggerDocument 物件。
    可以從 URL 查看 Swagger 產生器產生的 SwaggerDocument 物件。
    http://localhost:5000/swagger/v1/swagger.json
  • UseSwaggerUI
    SwaggerUI 是負責將 SwaggerDocument 物件變成漂亮的介面。
    預設 URL:http://localhost:5000/swagger

API 沿用 [鐵人賽 Day12] ASP.NET Core 2 系列 – RESTful API 的範例程式。

設定完成後,啟動網站就能開啟 Swagger UI 了。下面如下:

[鐵人賽 Day13] ASP.NET Core 2 系列 - Swagger UI

文件註解標籤

在 API 加入 <summary /> 文件註解標籤。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ...
[Route("api/[controller]s")]
public class UserController : Controller
{
    /// <summary>
    /// 查詢使用者清單
    /// </summary>
    /// <param name="q">查詢使用者名稱</param>
    /// <returns>使用者清單</returns>
    [HttpGet]
    public ResultModel Get(string q) {
        // ...
    }
}

再次打開 Swagger,會發現沒有顯示說明,因為沒有設定 .NET 的 XML 文件檔案,所以 Swagger 抓不到說明是正常的。

打開 *.csproj,在 <Project /> 區塊中插入以下程式碼:

1
2
3
4
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
    <DocumentationFile>bin\Debug\netcoreapp2.0\Api.xml</DocumentationFile>
    <NoWarn>1591</NoWarn>
</PropertyGroup>

以我範例的 *.csproj 內容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp2.0</TargetFramework>
  </PropertyGroup>

  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
    <DocumentationFile>bin\Debug\netcoreapp2.0\Api.xml</DocumentationFile>
    <NoWarn>1591</NoWarn>
  </PropertyGroup>

  <ItemGroup>
    <Folder Include="wwwroot\" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.3" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="1.1.0" />
  </ItemGroup>

</Project>

然後在 Swagger 產生器設定讀取 <DocumentationFile> 指定的 XML 文件檔案位置:

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.AddSwaggerGen(c =>
        {
            // ...
            var filePath = Path.Combine(PlatformServices.Default.Application.ApplicationBasePath, "Api.xml");
            c.IncludeXmlComments(filePath);
        });
    }
}

回傳格式

以 RESTful API 的例子來看,回傳的格式都是 JSON,所以可以直接在 Controller 加上 [Produces("application/json")] 表示回傳的型別都是 JSON,在 Swagger 的 Response Content Type 選項就會被鎖定只有 application/json 可以使用。如下:

1
2
3
4
5
6
7
// ...
[Route("api/[controller]s")]
[Produces("application/json")]
public class UserController : Controller
{
    // ...
}

回傳型別

若有預期 API 在不同的 HTTP Status Code 時,會回傳不同的物件,可以透過 [ProducesResponseType(type)] 定義回傳的物件。在 Swagger 中就可以清楚看到該 API 可能會發生的 HTTP Status Code 及回傳物件。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ...
[Route("api/[controller]s")]
[Produces("application/json")]
public class UserController : Controller
{
    /// <summary>
    /// 查詢使用者清單
    /// </summary>
    /// <param name="q">查詢使用者名稱</param>
    /// <returns>使用者清單</returns>
    [HttpGet]
    [ProducesResponseType(typeof(ResultModel<IEnumerable<UserModel>>), 200)]
    [ProducesResponseType(typeof(ResultModel<string>), 500)]
    public ResultModel<IEnumerable<UserModel>> Get(string q)
    {
        // ...
    }
}

執行結果

[鐵人賽 Day13] ASP.NET Core 2 系列 - Swagger - 執行結果

參考

ASP.NET Core Web API Help Pages using Swagger
Swagger tools for documenting API’s built on ASP.NET Core

[鐵人賽 Day12] ASP.NET Core 2 系列 – REST-Like API

RESTful 幾乎已算是 API 設計的標準,透過 HTTP Method 區分新增(Create)、查詢(Read)、修改(Update)跟刪除(Delete),簡稱 CRUD 四種資料存取方式,簡約又直覺的風格,讓人用的愛不釋手。
本篇將介紹如何透過 ASP.NET Core 實作 REST-Like API。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day12] ASP.NET Core 2 系列 – REST-Like API

HTTP Method

REST-Like API 對資料的操作行為,透過 HTTP Method 分為以下四種方式:

  • 新增(Create)
    用 HTTP POST 透過 Body 傳遞 JSON 或 XML 格式的資料給 Server。例如:

    1
    2
    3
    4
    5
    POST http://localhost:5000/api/users
    {
       "id": 1,
       "name": "John Wu"
    }
  • 查詢(Read)
    用 HTTP GET 透過 URL 帶查詢參數。通常查詢單一資源會用路由參數(Routing Parameter)帶上唯一值(Primary Key);多筆查詢會用複數,而查詢條件用 Query String。例如:

    1
    2
    3
    4
    5
    6
    # 單筆查詢
    GET http://localhost:5000/api/users/1
    # 多筆查詢
    GET http://localhost:5000/api/users
    # 多筆查詢帶條件
    GET http://localhost:5000/api/users?q=john
  • 修改(Update)
    修改資料如同查詢跟新增的組合,用 HTTP PUT 透過 URL 帶路由參數,作為找到要修改的目標;再透過 Body 傳遞 JSON 或 XML 格式的資料給 Server。例如:

    1
    2
    3
    4
    PUT http://localhost:5000/api/users/1
    {
       "name": "John"
    }
  • 刪除(Delete)
    刪除資料同查詢,用 HTTP DELETE 透過 URL 帶路由參數,作為找到要刪除的目標。例如:

    1
    DELETE http://localhost:5000/api/users/1

HTTP Method Attribute

[鐵人賽 Day06] ASP.NET Core 2 系列 – MVC 有提到,過去 ASP.NET MVC 把 MVC 及 Web API 的套件分開,但在 ASP.NET Core 中 MVC 及 Web API 用的套件是相同的。所以只要裝 Microsoft.AspNetCore.Mvc 套件就可以用 Web API 了。路由方式也跟 [鐵人賽 Day07] ASP.NET Core 2 系列 – 路由 (Routing) 介紹的 RouteAttribute 差不多,只是改用 HTTP Method Attribute。

HTTP Method Attribute 符合 RESTful 原則的路由設定方式如下:

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
[Route("api/[controller]s")]
public class UserController : Controller
{
    [HttpGet]
    public List<UserModel> Get(string q)
    {
        // ...
    }

    [HttpGet("{id}")]
    public UserModel Get(int id)
    {
        // ...
    }

    [HttpPost]
    public int Post([FromBody]UserModel user)
    {
        // ...
    }

    [HttpPut("{id}")]
    public void Put(int id, [FromBody]UserModel user)
    {
        // ...
    }

    [HttpDelete("{id}")]
    public void Delete(int id)
    {
        // ...
    }
}

目前 ASP.NET Core 還沒有像 ASP.NET MVC 的 MapHttpAttributeRoutes 可以綁 Http Method 的全域路由,都要在 Action 加上 HTTP Method Attribute。

SerializerSettings

用以下程式碼,舉例 SerializerSettings

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 UserModel
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }    
    public string PhoneNumber { get; set; }
    public string Address { get; set; }
}

// ...

[Route("api/[controller]s")]
public class UserController : Controller
{
  [HttpGet("{id}")]
  public UserModel Get(int id)
  {
      return new UserModel {
          Id = 1,
          Name = "John Wu"
      };
  }
}

camel Case

過去 ASP.NET Web API 2 預設是 Pascal Case;而 ASP.NET Core 預設是使用 camel Case。
若想要指定用 ContractResolver,可以在 Startup.cs 的 ConfigureServices 加入 MVC 服務時,使用 AddJsonOptions 設定如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ...
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {        
        services.AddMvc()
                .AddJsonOptions(options => 
                {
                    options.SerializerSettings.ContractResolver 
                        = new CamelCasePropertyNamesContractResolver();
                });
        // 同以下寫法:
        // services.AddMvc();
    }
}

呼叫 http://localhost:5000/api/users/1 會回傳 JSON 如下:

1
2
3
4
5
6
7
{
    "id": 1,
    "name": "John Wu",
    "email": null,
    "phoneNumber": null,
    "address": null
}

Pascal Case

若想保持跟 ASP.NET Web API 2 一樣使用 Pascal Case,ContractResolver 則改用 DefaultContractResolver

1
2
3
4
5
6
7
8
9
10
11
12
13
// ...
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {        
        services.AddMvc()
                .AddJsonOptions(options => 
                {
                    options.SerializerSettings.ContractResolver 
                        = new DefaultContractResolver();
                });
    }
}

DefaultContractResolver 名稱是延續 ASP.NET,雖然名稱叫 Default,但在 ASP.NET Core 它不是 DefaultCamelCasePropertyNamesContractResolver 才是 ASP.NET Core 的 Default ContractResolver。

呼叫 http://localhost:5000/api/users/1 會回傳 JSON 如下:

1
2
3
4
5
6
7
{
    "Id": 1,
    "Name": "John Wu",
    "Email": null,
    "PhoneNumber": null,
    "Address": null
}

Ignore Null

上述兩個 JSON 回傳,都帶有 null 的欄位。在轉型的過程,找不到欄位會自動轉成 null,傳送的過程忽略掉也沒差,反而可以節省到一點流量。

1
2
3
4
5
6
7
8
9
10
11
12
13
// ...
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {        
        services.AddMvc()
                .AddJsonOptions(options => 
                {
                    options.SerializerSettings.NullValueHandling 
                        = Newtonsoft.Json.NullValueHandling.Ignore;
                });
    }
}

呼叫 http://localhost:5000/api/users/1 會回傳 JSON 如下:

1
2
3
4
{
    "id": 1,
    "name": "John Wu"
}

範例程式

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc()
                .AddJsonOptions(options => {
                    options.SerializerSettings.NullValueHandling
                        = Newtonsoft.Json.NullValueHandling.Ignore;
                });
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseMvc();
    }
}

Models\ResultModel.cs

1
2
3
4
5
6
7
8
9
namespace MyWebsite.Models
{
    public class ResultModel
    {
        public bool IsSuccess { get; set; }
        public string Message { get; set; }
        public object Data { get; set; }
    }
}

我習慣用一個 ResultModel 來包裝每個 API 回傳的內容,不論調用 Web API 成功失敗都用此物件包裝,避免直接 throw exception 到 Client,產生 HTTP Status 200 以外的狀態。

Controllers/UserController.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
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc;
using MyWebsite.Models;

namespace MyWebsite.Controllers
{
    [Route("api/[controller]s")]
    public class UserController : Controller
    {
        private static List<UserModel> _users = new List<UserModel>();

        [HttpGet]
        public ResultModel Get(string q)
        {
            var result = new ResultModel();
            result.Data = _users.Where(c => string.IsNullOrEmpty(q) 
                                         || Regex.IsMatch(c.Name, q, RegexOptions.IgnoreCase));
            result.IsSuccess = true;
            return result;
        }

        [HttpGet("{id}")]
        public ResultModel Get(int id)
        {
            var result = new ResultModel();
            result.Data = _users.SingleOrDefault(c => c.Id == id);
            result.IsSuccess = true;
            return result;
        }

        [HttpPost]
        public ResultModel Post([FromBody]UserModel user)
        {
            var result = new ResultModel();
            user.Id = _users.Count() == 0 ? 1 : _users.Max(c => c.Id) + 1;
            _users.Add(user);
            result.Data = user.Id;
            result.IsSuccess = true;
            return result;
        }

        [HttpPut("{id}")]
        public ResultModel Put(int id, [FromBody]UserModel user)
        {
            var result = new ResultModel();
            int index;
            if ((index = _users.FindIndex(c => c.Id == id)) != -1)
            {
                _users[index] = user;
                result.IsSuccess = true;
            }
            return result;
        }

        [HttpDelete("{id}")]
        public ResultModel Delete(int id)
        {
            var result = new ResultModel();
            int index;
            if ((index = _users.FindIndex(c => c.Id == id)) != -1)
            {
                _users.RemoveAt(index);
                result.IsSuccess = true;
            }
            return result;
        }
    }
}

執行結果

透過 Postman 測試 API。

  • 新增(Create)
    [鐵人賽 Day12] ASP.NET Core 2 系列 - REST-Like API - 新增(Create)
  • 查詢(Read)
    [鐵人賽 Day12] ASP.NET Core 2 系列 - REST-Like API - 查詢(Read)
  • 修改(Update)
    [鐵人賽 Day12] ASP.NET Core 2 系列 - REST-Like API - 修改(Update)
  • 刪除(Delete)
    [鐵人賽 Day12] ASP.NET Core 2 系列 - REST-Like API - 刪除(Delete)

2018/01/02 補充

經大師指點,原標題為 ASP.NET Core 2 系列 – RESTful API,但範例未符合 HATEOAS(Hypermedia As The Engine Of Application State) 原則,所以不得稱為 RESTful API。

RESTful API 有四個重要的原則要遵守:

  1. Level 0
    使用 HTTP 做為資料傳輸的媒介。
  2. Level 1
    不要提供一個包山包海的 API,而是要區分資源,每個資源都該有對應的 API。
  3. Level 2
    透過 HTTP Method 區分新增(Create)、查詢(Read)、修改(Update)跟刪除(Delete)。
  4. Level 3
    對同資源可以用鏈結表達的方式,向下延伸查詢或修改。
    參考範例:HATEOAS

因本篇範例未符合 Level 3 HATEOAS 原則,所以把標題改為 ASP.NET Core 2 系列 – REST-Like API

參考

Routing in ASP.NET Core
Attribute Routing in ASP.NET Core
Richardson Maturity Model
HATEOAS

[鐵人賽 Day11] ASP.NET Core 2 系列 – Cookies & Session

基本上 HTTP 是沒有紀錄狀態的協定,但可以透過 Cookies 將 Request 來源區分出來,並將部分資料暫存於 Cookies 及 Session,是寫網站常用的用戶資料暫存方式。
本篇將介紹如何在 ASP.NET Core 使用 Cookie 及 Session。

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

Cookies

Cookies 是將用戶資料存在 Client 的瀏覽器,每次 Request 都會把 Cookies 送到 Server。
在 ASP.NET Core 中要使用 Cookie,可以透過 HttpContext.Request 及 HttpContext.Response 存取:

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

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

        public void Configure(IApplicationBuilder app)
        {
            app.Run(async (context) =>
            {
                string message;

                if (!context.Request.Cookies.TryGetValue("Sample", out message))
                {
                    message = "Save data to cookies.";
                }
                context.Response.Cookies.Append("Sample", "This is Cookies.");
                // 刪除 Cookies 資料
                //context.Response.Cookies.Delete("Sample");

                await context.Response.WriteAsync($"{message}");
            });
        }
    }
}

從 HTTP 可以看到傳送跟收到的 Cookies 資訊:

[鐵人賽 Day11] ASP.NET Core 2 系列 - Cookies & Session - Cookies

當存在 Cookies 的資料越多,封包就會越大,因為每個 Request 都會帶著 Cookies 資訊。

Session

Session 是透過 Cookies 內的唯一識別資訊,把用戶資料存在 Server 端記憶體、NoSQL 或資料庫等。
要在 ASP.NET Core 使用 Session 需要先加入兩個服務:

  • Session 容器
    Session 可以存在不同的地方,透過 DI IDistributedCache 物件,讓 Session 服務知道要將 Session 存在哪邊。
    (之後的文章會介紹到 IDistributedCache 分散式快取)
  • Session 服務
    在 DI 容器加入 Session 服務。並將 Session 的 Middleware 加入 Pipeline。

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

namespace MyWebsite
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            // 將 Session 存在 ASP.NET Core 記憶體中
            services.AddDistributedMemoryCache();
            services.AddSession();
        }

        public void Configure(IApplicationBuilder app)
        {
            // SessionMiddleware 加入 Pipeline
            app.UseSession();

            app.Run(async (context) =>
            {
                context.Session.SetString("Sample", "This is Session.");
                string message = context.Session.GetString("Sample");
                await context.Response.WriteAsync($"{message}");
            });
        }
    }
}

HTTP Cookies 資訊如下:

[鐵人賽 Day11] ASP.NET Core 2 系列 - Cookies & Session - Session

可以看到多出了 .AspNetCore.Session.AspNetCore.Session 就是 Session 的唯一識別資訊。
每次 Request 時都會帶上這個值,當 Session 服務取得這個值後,就會去 Session 容器找出專屬這個值的 Session 資料。

物件型別

以前 ASP.NET 可以將物件型別直接存放到 Session,現在 ASP.NET Core Session 不再自動序列化物件到 Sesson。
如果要存放物件型態到 Session 就要自己序列化了,這邊以 JSON 格式作為範例:

Extensions\SessionExtensions.cs

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

namespace MyWebsite.Extensions
{
    public static class SessionExtensions
    {
        public static void SetObject<T>(this ISession session, string key, T value)
        {
            session.SetString(key, JsonConvert.SerializeObject(value));
        }

        public static T GetObject<T>(this ISession session, string key)
        {
            var value = session.GetString(key);
            return value == null ? default(T) : JsonConvert.DeserializeObject<T>(value);
        }
    }
}

透過上例擴充方法,就可以將物件存取至 Session,如下:

1
2
3
4
using MyWebsite.Extensions;
// ...
var user = context.Session.GetObject<UserModel>("user");
context.Session.SetObject("user", user);

安全性

雖然 Session 資料都存在 Server 端看似安全,但如果封包被攔截,只要拿到 .AspNetCore.Session 就可以取到該用戶資訊,也是有風險。
有些安全調整建議實作:

  • SecurePolicy
    限制只有在 HTTPS 連線的情況下,才允許使用 Session。如此一來變成加密連線,就不容易被攔截。
  • IdleTimeout
    修改合理的 Session 到期時間。預設是 20 分鐘沒有跟 Server 互動的 Request,就會將 Session 變成過期狀態。
    (20分鐘有點長,不過還是要看產品需求。)
  • Name
    沒必要將 Server 或網站技術的資訊爆露在外面,所以預設 Session 名稱 .AspNetCore.Session 可以改掉。
1
2
3
4
5
6
7
8
9
10
11
// ...
public void ConfigureServices(IServiceCollection services)
{
    services.AddDistributedMemoryCache();
    services.AddSession(options =>
    {
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.Name = "mywebsite";
        options.IdleTimeout = TimeSpan.FromMinutes(5);
    });
}

強型別

由於 Cookies 及 Session 預設都是使用字串的方式存取資料,弱型別無法在開發階段判斷有沒有打錯字,還是建議包裝成強行別比較好。
而且直接存取 Cookies/Session 的話邏輯相依性太強,對單元測試很不友善,所以還是建議包裝一下。

Wappers\SessionWapper.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
using Microsoft.AspNetCore.Http;
using MyWebsite.Extensions;
// ...

public interface ISessionWapper
{
    UserModel User { get; set; }
}

public class SessionWapper : ISessionWapper
{
    private static readonly string _userKey = "session.user";
    private readonly IHttpContextAccessor _httpContextAccessor;

    public SessionWapper(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    private ISession Session
    {
        get
        {
            return _httpContextAccessor.HttpContext.Session;
        }
    }

    public UserModel User
    {
        get
        {
            return Session.GetObject<UserModel>(_userKey);
        }
        set
        {
            Session.SetObject(_userKey, value);
        }
    }
}

在 DI 容器中加入 IHttpContextAccessor 及 ISessionWapper,如下:

Startup.cs

1
2
3
4
5
6
// ...
public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
    services.AddSingleton<ISessionWapper, SessionWapper>();
}
  • IHttpContextAccessor
    ASP.NET Core 實作了 IHttpContextAccessor,讓 HttpContext 可以輕鬆的注入給需要用到的物件使用。
    由於 IHttpContextAccessor 只是取用 HttpContext 實例的接口,用 Singleton 的方式就可以供其它物件使用。

在 Controller 就可以直接注入 ISessionWapper,以強行別的方式存取 Session,如下:

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.AspNetCore.Mvc;
using MyWebsite.Wappers;

namespace MyWebsite.Controllers
{
    public class HomeController : Controller
    {
        private readonly ISessionWapper _sessionWapper;

        public HomeController(ISessionWapper sessionWapper)
        {
            _sessionWapper = sessionWapper;
        }

        public IActionResult Index()
        {
            var user = _sessionWapper.User;
            _sessionWapper.User = user;
            return Ok(user);
        }
    }
}

參考

Introduction to session and application state in ASP.NET Core

[鐵人賽 Day10] ASP.NET Core 2 系列 – Views

ASP.NET Core MVC 中的 Views 是負責網頁顯示,將資料一併渲染至 UI 包含 HTML、CSS 等。並能透過 Razor 語法在 *.cshtml 撰寫渲染畫面的程式邏輯。
本篇將介紹 ASP.NET Core MVC 的 Views。

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

之前 [鐵人賽 Day06] ASP.NET Core 2 系列 – MVC 有稍微介紹到 Views 及 Controller 的對應關係,這邊就不重複說明。

Razor 語法

ASP.NET Core MVC 的 Views 預設是使用 Razor 引擎,Views 的副檔名是用 *.cshtml
檔案內容以 HTML 為主,但可以透過 @ Razor 語法撰寫 C# 程式。
可以假想一下 *.cshmtl 就是一般的 HTML,而 Razor 語法是 C# 程式跟靜態 HTML 溝同的媒介。

@ 就是 Razor 語法最重要的溝同媒介,在 C# 變數前面冠上 @,就可以將 C# 程式混合制 HTML 輸出。
如果要在 HTML 顯示 @ 符號的話,可以連用兩個 @ 符號,就可以把 @ 字元輸出,範例如下:

1
2
3
4
5
<div>@@DateTime.Now @DateTime.Now</div>
<div>
    @@(DateTime.Now - TimeSpan.FromDays(7)) 
    @(DateTime.Now - TimeSpan.FromDays(7))
</div>

實際輸出的 HTML 結果:

1
2
3
4
5
<div>@DateTime.Now 2017/12/29 上午 01:23:45</div>
<div>
    @(DateTime.Now - TimeSpan.FromDays(7)) 
    2017/12/22 上午 01:23:45
</div>

控制結構 (Control Structures)

如果有需要也可以在 Views 撰寫 C# 程式,透過 @{ } 定義程式區塊,便可以在 Views 中寫 C# 程式,如下:

1
2
3
4
5
@{
    var date = DateTime.Now;
    date -= TimeSpan.FromDays(7);
}
<div>@date</div>

也可以在迴圈、判斷式或 C# 區塊的關鍵字前加上 @ 宣告程式區塊,如:@if@switch@for@foreach@while@do{ }while()@try@using@lock
範例如下:

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
@{
    bool flag = true;
    int number = 3;
}

@if(flag)
{
  <div>flag is true</div>
} 
else
{
  <div>flag is false</div>
}

@switch(number)
{
    case 3:
        <div>number is lucky 3!!!!</div>
        break;
    default:
        <div>number is @number</div>
        break;
}

@for(var i = 0; i < number; i++)
{
    <div>For sample: @i</div>
}

@try
{
    throw new Exception("something wrong");
}
catch(Exception ex)
{
    <div>@ex.Message</div>
}

輸出畫面:
[鐵人賽 Day10] ASP.NET Core 2 系列 - Views - Razor 語法

指令 (Directives)

Razor Views 會被 Razor 引擎動態轉換成 Class,所以也有些類似 C# Class 的方法可以使用。

  • @using
    同 C# Class 的 using,載入不同 namespaces,簡化使用時的名稱。例:

    1
    2
    3
    4
    5
    @using System.IO
    @{
        var dir = Directory.GetCurrentDirectory();
    }
    <p>@dir</p>
  • @model
    用來綁定 Controller 傳來的 Model 型別,並填入 Model 屬性中,在 Views 中就可以透過 Model 取得 Controller 傳來的 Model。例:

    1
    2
    3
    @using MyWebsite.Models
    @model UserModel
    Hello~ 我是 @Model.Name
  • @inherits
    讓 Razor View 繼承其他自訂的 RazorPage 類別。例:
    CustomRazorPage.cs

    1
    2
    3
    4
    5
    6
    using Microsoft.AspNetCore.Mvc.Razor;
    
    public abstract class CustomRazorPage<TModel> : RazorPage<TModel>
    {
        public string CustomText { get; } = "CustomRazorPage.CustomText";
    }

    Sample.cshtml

    1
    2
    @inherits CustomRazorPage<TModel>
    <div>Custom text: @CustomText</div>
  • @inject
    將 DI 容器的 Service 注入至 Razor View 使用。
    (DI 可以參考這篇:[鐵人賽 Day04] ASP.NET Core 2 系列 – 依賴注入 (Dependency Injection))
  • @functions
    在 Razor View 定義方法。例:

    1
    2
    3
    4
    5
    6
    7
    @functions {
        public string GetHello()
        {
            return "Hello";
        }
    }
    <div>From method: @GetHello()</div>
  • @section
    配合 Layout 排版使用,下面會介紹。

Layout

通常同網站的頁面都有類似的風格,可能只有部分的內容會不一樣,這種清況很適合用 Layout。
以下圖為例,網站的每頁都會有 Header 及 Footer 而且都長的一樣,就只有 Content 會不同。

[鐵人賽 Day10] ASP.NET Core 2 系列 - Views - Layout

通常 Layout 都會放在 Views\Shared 資料夾,建立一個 _Layout.cshtml

Views\Shared\_Layout.cshtml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
    <title>@ViewBag.Title</title>
    @RenderSection("styles", required: false)
</head>
<body>
    <header>
        Layout Header
    </header>
    <div>
        <h1>@ViewBag.Title</h1>
        @RenderBody()
    </div>
    <footer>
        Layout footer
    </footer>
    @RenderSection("scripts", required: false)
</body>
</html>

在要套用 Layout 的 Views,指派要套用的 Layout 名稱,如下:

Views\Home\Index.cshtml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@using MyWebsite.Models
@model UserModel
@{
    Layout = "_Layout";
    ViewBag.Title = "Sample";
}

<div>Hello~ 我是 @Model.Name</div>

@section styles {
  <link rel="stylesheet" type="text/css" href="/css/theme.css">
}
@section scripts {
  <script type="text/javascript" src="/js/jquery.js"></script>
}
  • Layout
    Layout 是指定要套用 Layout 的名稱,預設會在資料夾 Views\Shared 尋找 {Layout 名稱}.cshtml
    也可以指定完整路徑,如:Layout = "/Views/Shared/_Layout.cshtml"
  • ViewBag
    ViewBag 是 Dynamic 類型的物件,可以在同一個 Request 中,跨 Controller 及 Views 存取資料。
  • @section
    在使用 Layout 時,並不一定會將 Razor View 全部填入至 RenderBody,可能會有需求將某些內容填入至 Layout 的其他地方。如:*.css 的引用填入至 <head></head> 中;*.js 的引用填入至 </body> 之前。

當打開 http://localhost:5000/home/index 時,Razor 引擎會將 Index.cshtml 的結果都填入 Views\Shared\_Layout.cshtml的 @RenderBody()
實際輸出的 HTML 結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
    <title>Sample</title>
    <link rel="stylesheet" type="text/css" href="/css/theme.css">
</head>
<body>
    <header>
        Layout Header
    </header>
    <div>
        <h1>Sample</h1>
        <div>Hello~ 我是 John</div>
    </div>
    <footer>
        Layout footer
    </footer>
    <script type="text/javascript" src="/js/jquery.js"></script>
</body>
</html>

_ViewImports

上例 Views\Home\Index.cshtml 有用到 @using MyWebsite.Models,實務上可能每個 Razor View 都會用到 @using MyWebsite.Models,如果每個 *.cshtml 都加上這行就會顯得有點笨拙。
可以透過 _ViewImports.cshtml 把通用性的 @using 都加到這邊,如此一來就可以套用到全部的 Razor View,如:

Views\_ViewImports.cshtml

1
2
3
4
@using System.IO
@using System.Collections.Generic
@using MyWebsite
@using MyWebsite.Models

如此一來就能將 Views\Home\Index.cshtml 第一行的 @using MyWebsite.Models 移除。

_ViewStart

指定 Layout 也會有套用全部 Razor View 的需求,可以透過 _ViewStart.cshtml,在 Razor View 的第一個渲染事件指派預設 Layout,如:

Views\_ViewStart.cshtml

1
2
3
@{
    Layout = "_Layout";
}

Partial Views

有些重複性很高的畫面,如果散落在各個 Razor View,在維護上就會比較麻煩。
可以透過 Partial Views 把重複的內容變成組件,再重複使用。範例如下:

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
// ...
public class HomeController : Controller
{
    public IActionResult Index(int id)
    {
        return View(new List<UserModel>()
        {
            new UserModel()
            {
                Id = 1,
                Name = "John",
                Email = "john@xxx.xxx",
            },
            new UserModel()
            {
                Id = 2,
                Name = "Blackie",
                Email = "blackie@xxx.xxx"
            },
            new UserModel()
            {
                Id = 3,
                Name = "Claire",
                Email = "claire@xxx.xxx"
            }
        });
    }
}

Views\Home\_UserInfo.cshtml

1
2
3
4
5
6
@model UserModel
<div>
    <label>Id:</label>@Model.Id <br />
    <label>Name:</label>@Model.Name <br />
    <label>Email:</label>@Model.Email <br />
</div>

Views\Home\Index.cshtml

1
2
3
4
5
6
7
8
9
10
@model List<UserModel>
@{
    ViewBag.Title = "User List";
}

@foreach(var user in Model)
{
    @Html.Partial("_UserInfo", user)
    <hr />
}

實際輸出的 HTML 結果:

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
<!DOCTYPE html>
<html>
<head>
    <title>User List</title>
</head>
<body>
    <header>
        Layout Header
    </header>
    <div>
        <h1>User List</h1>
        <div>
            <label>Id:</label>1 <br />
            <label>Name:</label>John <br />
            <label>Email:</label>john@xxx.xxx <br />
        </div>
        <hr />
        <div>
            <label>Id:</label>2 <br />
            <label>Name:</label>Blackie <br />
            <label>Email:</label>blackie@xxx.xxx <br />
        </div>
        <hr />
        <div>
            <label>Id:</label>3 <br />
            <label>Name:</label>Claire <br />
            <label>Email:</label>claire@xxx.xxx <br />
        </div>
        <hr />
    </div>
    <footer>
        Layout footer
    </footer>
</body>
</html>

後記

Views 的渲染過程都還是在 Server 端,所以可以透過 Razor 撰寫 C# 程式。
Razor 引擎最終會將渲染的結果以 HTML 的方式回傳給 Client。
回顧 Day06 資料流動畫:

[鐵人賽 Day06] ASP.NET Core 2 系列 - MVC - 資料流

要注意的是,Razor 的渲染是耗用 Server 的 CPU 資源,如果有多筆數的資料透過迴圈產生 HTML,也會變成網路傳輸的負擔。如果要注重效能,建議用 Single Page Application(SPA) 的方式取代 Razor。

參考

Views in ASP.NET Core MVC
Razor syntax for ASP.NET Core
Partial Views

[鐵人賽 Day09] ASP.NET Core 2 系列 – Model Binding

ASP.NET Core MVC 的 Model Binding 會將 HTTP Request 資料,以映射的方式對應到相對到參數中。基本上跟 ASP.NET MVC 差不多,但能 Binding 的來源更多了一些。
本篇將介紹 ASP.NET Core 的 Model Binding。

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

Model Binding

要接收 Client 傳送來的資料,可以透過 Action 的參數接收,如下:

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

namespace MyWebsite.Controllers
{
    public class HomeController : Controller
    {
        public IActionResult Index(int id)
        {
            return Content($"id: {id}");
        }
    }
}

id 就是從 HTTP Requset 的內容被 Binding 的 Model 參數。
預設的 Model Binding 會從 HTTP Requset 的三個地方取值 (優先順序由上到下) :

  • Form
    透過 HTTP POST 的 form 取值。如下圖:
    [鐵人賽 Day09] ASP.NET Core 2 系列 - Model Binding - Form
  • Route
    是透過 MVC Route URL 取值。
    如:http://localhost:5000/Home/Index/2id 取出的值就會是 2。
  • Query
    是透過 URL Query 參數取值。
    如:http://localhost:5000/Home/Index?id=1id 取出的值就會是 1。

如果三者都傳入的話,會依照優先順序取值 Form > Route > Query。

Binding Attributes

除了預設的三種 Binding 來源外,還可以透過 Model Binding Attributes 從 HTTP Requset 的其他資訊中 Binding。有以下 6 種類別:

  • [FromHeader]
    從 HTTP Header 取值。
  • [FromForm]
    透過 HTTP POST 的 form 取值。
  • [FromRoute]
    是透過 MVC Route URL 取值。
  • [FromQuery]
    是透過 URL Query 參數取值。
  • [FromBody]
    從 HTTP Body 取值,通常用於取 JSON, XML。
    ASP.NET Core MVC 預設的序列化是使用 JSON,如果要傳 XML 格式做 Model Binding 的話,要在 MVC 服務加入 XmlSerializerFormatters,如下:

    Startup.cs

    1
    2
    3
    4
    5
    6
    // ...
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc()
                .AddXmlSerializerFormatters();
    }
  • [FromServices]
    這個比較特別,不是從 HTTP Requset 取值,而是從 DI 容器取值。
    DI 預設是使用 Constructor Injection,但 Controller 可能會因為每個 Action 用到不一樣的 Service 導致很多參數,所以也可以在 Action 注入 Service。

範例程式

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
// ...
public class HomeController : Controller
{
    public IActionResult FirstSample(
        [FromHeader]string header,
        [FromForm]string form,
        [FromRoute]string id,
        [FromQuery]string query)
    {
        return Content($"header: {header}, form: {form}, id: {id}, query: {query}");
    }
    
    public IActionResult DISample([FromServices] ILogger<HomeController> logger)
    {
        return Content($"logger is null: {logger == null}.");
    }

    public IActionResult BodySample([FromBody]UserModel model)
    {
        return Ok(model);
    }
}

// ...
public class UserModel
{
    public int Id { get; set; }        
    public string Name { get; set; }        
    public string Email { get; set; }        
    public string PhoneNumber { get; set; }        
    public string Address { get; set; }
}

輸出結果

FirstSample 輸出結果:
[鐵人賽 Day09] ASP.NET Core 2 系列 - Model Binding - Binding Attributes

DISample 輸出結果:
http://localhost:5000/Home/DISample

1
logger is null: False.

BodySample 輸出結果:

  • JSON
    [鐵人賽 Day09] ASP.NET Core 2 系列 - Model Binding - Binding Attributes
  • XML
    [鐵人賽 Day09] ASP.NET Core 2 系列 - Model Binding - Binding Attributes

Model 驗證

Model Binding 也可以順便幫忙驗證欄位資料,只要在資料模型的屬性上面帶上 Validation Attributes,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System.ComponentModel.DataAnnotations;
// ...
public class UserModel
{
    [Required]
    public int Id { get; set; }

    [RegularExpression(@"\w+")]
    [StringLength(20, MinimumLength = 4)]
    public string Name { get; set; }

    [EmailAddress]
    public string Email { get; set; }

    [Phone]
    public string PhoneNumber { get; set; }

    [StringLength(200)]
    public string Address { get; set; }
}

然後在 Action 加上判斷:

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

namespace MyWebsite.Controllers
{
    public class HomeController : Controller
    {
        // ...
        public IActionResult BodySample([FromBody]UserModel model)
        {
            // 由於 Id 是 int 型別,int 預設為 0
            // 雖然有帶上 [Required],但不是 null 所以算是有值。
            if (model.Id < 1)
            {
                ModelState.AddModelError("Id", "Id not exist");
            }
            if (ModelState.IsValid)
            {
                return Ok(model);
            }
            return BadRequest(ModelState);
        }
    }
}

輸入錯誤資料的輸出結果:

[鐵人賽 Day09] ASP.NET Core 2 系列 - Model Binding - Model 驗證

.NET Core 提供了很多的 Validation Attributes,可以參考官網:System.ComponentModel.DataAnnotations

自製 Validation Attributes

如果 .NET Core 提供的 Validation Attributes 不夠用還可以自己做。
例如上述範例的資料模型多了生日欄位,需要驗證年齡:

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.ComponentModel.DataAnnotations;
// ...
public class UserModel
{
    [Required]
    public int Id { get; set; }

    [RegularExpression(@"\w+")]
    [StringLength(20, MinimumLength = 4)]
    public string Name { get; set; }

    [EmailAddress]
    public string Email { get; set; }

    [Phone]
    public string PhoneNumber { get; set; }

    [StringLength(200)]
    public string Address { get; set; }

    [DataType(DataType.Date)]
    [AgeCheck(18, 120)]
    public DateTime BirthDate { get; set; }
}

透過繼承 ValidationAttribute 就可以客製化自訂的 Model 驗證 Attributes,如下:

Attributes\AgeCheckAttribute.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
using System;
using System.ComponentModel.DataAnnotations;

namespace MyWebsite.Attributes
{
    public class AgeCheckAttribute : ValidationAttribute
    {
        public int MinimumAge { get; private set; }
        public int MaximumAge { get; private set; }

        public AgeCheckAttribute(int minimumAge, int maximumAge)
        {
            MinimumAge = minimumAge;
            MaximumAge = maximumAge;
        }

        protected override ValidationResult IsValid(object value, ValidationContext validationContext)
        {
            var date = Convert.ToDateTime(value);

            if (date.AddYears(MinimumAge) > DateTime.Today
                || date.AddYears(MaximumAge) < DateTime.Today)
            {
                return new ValidationResult(GetErrorMessage(validationContext));
            }

            return ValidationResult.Success;
        }

        private string GetErrorMessage(ValidationContext validationContext)
        {
            // 有帶 ErrorMessage 的話優先使用
            // [AgeCheck(18, 120, ErrorMessage="xxx")] 
            if (!string.IsNullOrEmpty(this.ErrorMessage))
            {
                return this.ErrorMessage;
            }

            // 自訂錯誤訊息
            return $"{validationContext.DisplayName} can't be in future";
        }
    }
}

參考

Overview of ASP.NET Core MVC
Introduction to model validation in ASP.NET Core MVC
ASP.NET CORE 2.0 MVC MODEL BINDING
ASP.NET CORE 2.0 MVC MODEL VALIDATION

[鐵人賽 Day08] ASP.NET Core 2 系列 – URL 重寫 (URL Rewrite)

路由跟 URL 重寫的功能性略有不同。路由是將 Request 找到對應的服務,而 URL 重寫是為了推卸責任 XD轉送 Request。
本篇將介紹 ASP.NET Core 的 URL 重寫 (URL Rewrite)。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day08] ASP.NET Core 2 系列 – URL 重寫 (URL Rewrite)

URL Rewrite 註冊

URL Rewriting Middleware 需要 Microsoft.AspNetCore.Rewrite 套件。
ASP.NET Core 2.0 以上版本,預設是參考 Microsoft.AspNetCore.All,已經包含 Microsoft.AspNetCore.Rewrite,所以不用再安裝。
URL 重寫功能是在 ASP.NET Core 1.1 之後的版本才有,如果是 ASP.NET Core 2.0 以前版本,可以透過 .NET Core CLI 在專案資料夾執行安裝指令:

1
dotnet add package Microsoft.AspNetCore.Rewrite

要使用 URL 重寫,在 Startup.cs 的 Configure 對 IApplicationBuilder 使用 UseRewriter 方法註冊 URL Rewriting Middleware:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
// ...
public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        var rewrite = new RewriteOptions()
            .AddRewrite("about.aspx", "home/about", skipRemainingRules: true)
            .AddRedirect("first", "home/index");
        app.UseRewriter(rewrite);
        // ...
    }
}

透過 RewriteOptions 建立 URL 重寫規則後,傳入給 URL Rewriting Middleware。
URL 重寫規則,主要有分兩種方式:

  • URL 重寫 (URL Rewrite)
    上例的 AddRewrite 就是 URL 重寫。
  • URL 轉址 (URL Redirect)
    上例的 AddRedirect 就是 URL 轉址。

URL 重寫

URL 重寫是屬於 Server 端的轉換事件,當 Client 端 Request 來的時候,發現原網址已經被換掉了,就會自動回傳新網址的內容。情境如下:

[鐵人賽 Day08] ASP.NET Core 2 系列 - URL 重寫 (URL Rewrite) - URL 重寫情境

上例 AddRewrite 有用到三個參數,當 URL 符合 參數 1 時,就將 參數 2 路由的內容回傳給 Client。
而 參數 3 是用來加速 URL 匹配的參數,類似 switch 的 break。若將 skipRemainingRules 設為 true,當找到匹配條件,就不再繼續往下找符合其他 參數 1 的規則。

  • 參數 1 支援正規表示式(Regular Expressions)。

範例結果:

[鐵人賽 Day08] ASP.NET Core 2 系列 - URL 重寫 (URL Rewrite) - URL 重寫 - 範例結果

URL 轉址

URL 重寫是屬於 Client 端的轉換事件,當 Client 端 Request 來的時候,發現原網址已經被換掉了,Server 會先回傳給 Client 告知新網址,再由 Client 重新 Request 新網址。情境如下:

[鐵人賽 Day08] ASP.NET Core 2 系列 - URL 重寫 (URL Rewrite) - URL 轉址情境

AddRedirect 的使用方式類似 AddRewrite,當 URL 符合 參數 1 時,就會回傳 參數 2 的 URL 給 Client。

  • 參數 1 同樣支援正規表示式(Regular Expressions)。

URL 轉址預設都是回傳 HTTP Status Code 302,也可以在 參數 3 指定回傳的 HTTP Status Code。
通常轉址的 HTTP Status Code 都是用 301 或 302 ,URL 轉址對 “人” 的行為來說沒有什麼意義,反正就是幫忙從 A 轉到 B;主要差異是給 “搜尋引擎” 理解的。

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
// ...
public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        var rewrite = new RewriteOptions()
            .AddRedirect("first", "home/index", 301);
        app.UseRewriter(rewrite);
        // ...
    }
}
  • HTTP Status Code 301
    301 是要讓搜尋引擎知道,該網址已經永久轉移到另一個地方。
    通常用於網站搬家或網站改版,新舊版本路徑不相同,要重新對應的情況。
    範例結果:
    [鐵人賽 Day08] ASP.NET Core 2 系列 - URL 重寫 (URL Rewrite) - URL 重寫 - HTTP Status Code 301 範例結果
  • HTTP Status Code 302
    302 是告知搜尋引擎,雖然這次被轉址,但只是暫時性的。
    通常用於網站維護時,暫時原網址轉移到別的地方,如維修公告頁面。
    範例結果:
    [鐵人賽 Day08] ASP.NET Core 2 系列 - URL 重寫 (URL Rewrite) - URL 重寫 - HTTP Status Code 302 範例結果

正規表示式

AddRewrite 及 AddRedirect 都支援正規表示式的使用,且能把來源的 URL 透過正規表示式變成參數,帶入新 URL。

範例程式:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
// ...
public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        var rewrite = new RewriteOptions()
            .AddRedirect("products.aspx?id=(\w+)", "prosucts/$1", 301);
            .AddRedirect("api/(.*)/(.*)/(.*)", "api?p1=$1&p2=$2&p3=$3", 301);
        app.UseRewriter(rewrite);
        // ...
    }
}
  • 當連到 http://localhost:5000/products.aspx?id=p123
    轉址到 http://localhost:5000/products/p123
  • 當連到 http://localhost:5000/api/first/second/third
    轉址到 http://localhost:5000/api?p1=first&p2=second&p3=third

透過正規表示式做 URL 轉址,對於網站新舊改版來說,非常好用。

參考

URL Rewriting Middleware in ASP.NET Core

[鐵人賽 Day07] ASP.NET Core 2 系列 – 路由 (Routing)

ASP.NET Core 透過路由(Routing)設定,將定義的 URL 規則找到相對應行為;當使用者 Request 的 URL 滿足特定規則條件時,則自動對應到相符的行為處理。
從 ASP.NET 就已經存在的架構,而且用法也很相似,只有些許的不同。
本篇將介紹 ASP.NET Core 的 Router Middleware 用法。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day07] ASP.NET Core 2 系列 – 路由 (Routing)

簡單路由

之前 [鐵人賽 Day03] ASP.NET Core 2 系列 – Middleware 有介紹到,可以透過 Map 處理一些簡單路由,例如:

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.Map("/first", mapApp =>
        {
            mapApp.Run(async context =>
            {
                await context.Response.WriteAsync("First. \r\n");
            });
        });
        app.Map("/second", mapApp =>
        {
            mapApp.Run(async context =>
            {
                await context.Response.WriteAsync("Second. \r\n");
            });
        });
    }
}

但要搭配 ASP.NET Core MVC 的話,簡單路由就沒這麼好用了。
RouterMiddleware 除了方便搭配 ASP.NET Core MVC 外,也可以比較彈性的使用路由定義。

路由註冊

RouterMiddleware 的路由註冊方式大致分為兩種:

  • 廣域註冊。如:MapRoute
  • 區域註冊。如:RouteAttribute

預設路由的順序如下:

[鐵人賽 Day07] ASP.NET Core 2 系列 - 路由(Routing) - 流程

路由的 Middleware 需要 Microsoft.AspNetCore.Routing 套件。
ASP.NET Core 2.0 以上版本,預設是參考 Microsoft.AspNetCore.All,已經包含 Microsoft.AspNetCore.Routing,所以不用再安裝。
如果是 ASP.NET Core 1.0 的版本,可以透過 .NET Core CLI 在專案資料夾執行安裝指令:

1
dotnet add package Microsoft.AspNetCore.Routing

在 Startup.cs 的 ConfigureServices 加入 Routing 的服務,並在 Configure 定義路由規則:

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
// ...
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddRouting();
    }

    public void Configure(IApplicationBuilder app)
    {
        var defaultRouteHandler = new RouteHandler(context =>
        {
            var routeValues = context.GetRouteData().Values;
            return context.Response.WriteAsync($"Route values: {string.Join(", ", routeValues)}");
        });

        var routeBuilder = new RouteBuilder(app, defaultRouteHandler);
        routeBuilder.MapRoute("default", "{first:regex(^(default|home)$)}/{second?}");

        routeBuilder.MapGet("user/{name}", context => {
            var name = context.GetRouteValue("name");
            return context.Response.WriteAsync($"Get user. name: {name}");
        });

        routeBuilder.MapPost("user/{name}", context => {
            var name = context.GetRouteValue("name");
            return context.Response.WriteAsync($"Create user. name: {name}");
        });

        var routes = routeBuilder.Build();
        app.UseRouter(routes);
    }
}

可以看到上面程式碼,建立了兩個物件:

  • RouteHandler:這個物件如同簡單路由的 Run 事件,當路由成立的時候,就會執行這個事件。
  • RouteBuilder:在這個物件定義路由規則,當 Requset URL 符合規則就會執行該事件。
    • MapRoute:預設的路由規則,可以支援正規表示式(Regular Expressions)。
    • MapGet & MapPost
      HTTP Method 路由:同樣的 URL 可以透過不同的 HTTP Method,對應不同的事件。

第一個路由 MapRoute 定義:

  • URL 第一層透過正規表示式必需是 default 或 home,並放到路由變數 first 中。
  • URL 第二層可有可無,如果有的話,放到路由變數 second 中。

第二個路由 MapGet 定義:

  • 指定要是 HTTP Get
  • URL 第一層必需是 user
  • URL 第二層必需要有值,放到路由變數 name 中。

第三個路由 MapPost 定義:

  • 指定要是 HTTP Post
  • URL 第一層必需是 user
  • URL 第二層必需要有值,放到路由變數 name 中。

以上設定的路由結果如下:

  • http://localhost:5000/default 會顯示:
    Route values: [first, default]
  • http://localhost:5000/home/about 會顯示:
    Route values: [first, home], [second, about]
  • http://localhost:5000/user/john 透過 HTTP Get 會顯示:
    Get user. name: john
  • http://localhost:5000/user/john 透過 HTTP Post 會顯示:
    Create user. name: john

MVC 路由

MVC 路由使用跟上面範例差不多,只是把事件指向 Controller 及 Action
ASP.NET Core MVC 註冊路由規則的方式跟 ASP.NET MVC 差不多。
可以註冊多個 MapRoute,每個 Request 會經過這些 RouterMiddleware 找到對應 Action。
先被執行到的路由,後面就會被跳過,所以越廣域的寫越下面,如下:

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
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc();
    }
    public void Configure(IApplicationBuilder app)
    {
        app.UseMvc(routes =>
        {
            routes.MapRoute(
                name: "about",
                template: "about",
                defaults: new { controller = "Home", action = "About" }
            );
            routes.MapRoute(
                name: "default",
                template: "{controller=Home}/{action=Index}/{id?}"
            );
            // 跟上面設定的 default 效果一樣
            //routes.MapRoute(
            //    name: "default",
            //    template: "{controller}/{action}/{id?}",
            //    defaults: new { controller = "Home", action = "Index" }
            //);
        });
    }
}

以上設定的路由結果如下:

  • http://localhost:5000 會對應到 HomeController 的 Index()。
  • http://localhost:5000/about 會對應到 HomeController 的 About()。
  • http://localhost:5000/home/test 會對應到 HomeController 的 Test()。

RouteAttribute

預設 RouteAttribute 的優先順序高於 Startup 註冊的 MapRoute,所以當使用 [Route] 後,原本的 MapRoute 將不再對 Controller 或 Action 產生作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[Route("[controller]")]
public class UserController : Controller
{
    [Route("")]
    public IActionResult Profile()
    {
        return View();
    }

    [Route("change-password")]
    public IActionResult ChangePassword()
    {
        return View();
    }

    [Route("[action]")]
    public IActionResult Other()
    {
        return View();
    }
}

以上設定的路由結果如下:

  • http://localhost:5000/user 會對應到 UserController 的 Profile()。
  • http://localhost:5000/user/change-password 會對應到 UserController 的 ChangePassword()。
  • http://localhost:5000/user/other 會對應到 UserController 的 Other()。

若 Controller 設定了 [Route],Action 就要跟著加 [Route],不然會發生錯誤。

如果只有特定的 Action 需要改路由,也可以只加 Action。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class UserController : Controller
{
    public IActionResult Profile()
    {
        return View();
    }

    [Route("change-password")]
    public IActionResult ChangePassword()
    {
        return View();
    }

    public IActionResult Other()
    {
        return View();
    }
}
  • http://localhost:5000/user/profile 會對應到 UserController 的 Profile()。
  • http://localhost:5000/change-password 會對應到 UserController 的 ChangePassword()。
  • http://localhost:5000/user/other 會對應到 UserController 的 Other()。

注意!如果 [Route] 是設定在 Action,路徑是由網站根路徑開始算。

參考

Routing in ASP.NET Core

[鐵人賽 Day06] ASP.NET Core 2 系列 – MVC

ASP.NET Core MVC 跟 ASP.NET MVC 觀念是一致的,使用上也沒有什麼太大的變化。
過往 ASP.NET MVC 把 MVC 及 Web API 的套件分開,但在 ASP.NET Core 中 MVC 及 Web API 用的套件是相同的。
本篇將介紹 ASP.NET Core MVC 設定方式。

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

MVC 簡介

ASP.NET Core 的 MVC(Model-View-Controller) 架構模式延續 ASP.NET MVC,把網站分成三大元件 ModelViewController,相依關係如下圖:

[鐵人賽 Day06] ASP.NET Core 2 系列 - MVC - 相依關係

  • Model
    負責資料處理,包含資料存取、商業邏輯、定義資料物件及驗證資料。
  • View
    負責 UI 顯示,如 HTML、CSS 等介面設計配置。
  • Controller
    負責將使用者 Requset 找到相對應的 Model 及 View,做為控制流程的角色。

在 ASP.NET Core 中使用 MVC 或 Web API,需要 Microsoft.AspNetCore.Mvc 套件。
ASP.NET Core 2.0 以上版本,預設是參考 Microsoft.AspNetCore.All,已經包含 Microsoft.AspNetCore.Mvc,所以不用再安裝。
如果是 ASP.NET Core 1.0 的版本,可以透過 .NET Core CLI 在專案資料夾執行安裝指令:

1
dotnet add package Microsoft.AspNetCore.Mvc

註冊 MVC 服務

安裝完成後,在 Startup.cs 的 ConfigureServices 加入 MVC 的服務,並在 Configure 對 IApplicationBuilder 使用 UseMvcWithDefaultRoute 方法註冊 MVC 預設路由的 Middleware。如下:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
// ...
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseMvcWithDefaultRoute();
    }
}

MVC 範例

Model

建立一個簡單的 Model 用於 Controller 跟 View 互動。

Models\UserModel.cs

1
2
3
4
public class UserModel
{
    public string Name { get; set; } = "John Wu";
}

Controller

在專案目錄下建立一個 Controllers 資料夾,把 Controller 都放這個目錄。
過去 ASP.NET 把 MVC 及 Web API 用的 Controller 分為 Controller 及 ApiController,現在 ASP.NET Core 把兩者合一,不再區分 ApiController
所以要建立一個類別,名稱後綴 Controller 即可,如下:

Controllers\HomeController.cs

1
2
3
4
5
6
7
public class HomeController
{
    public string Index()
    {
        return "This is HomeController.Index()";
    }
}

但要讓 Controller 跟 View 互動,還是需要繼承 Controller 比較方便,如下:

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
public class HomeController : Controller
{
    public IActionResult Index()
    {
        var user = new UserModel();
        return View(model: user);
    }
}

IActionResult 回傳的方式可以有很多種,透過繼承 Controller 後,就可以使用 Controller 的方法:

  • View
    以上例來說,透過回傳 View 方法,可以找到該 Controller & Action 對應的 *.cshtml, 並且把 UserModel 傳給 View 使用。
  • HTTP Status Code
    回應包含 HTTP Status。常用的回應有 OkBadRequestNotFound 等。
    例如:return BadRequest("Internal Server Error"),會回應 HTTP Status 400 及 Internal Server Error 字串。
  • Redirect
    可以把 Request 轉給其他的 Action 或 URL。轉向的方法有 RedirectLocalRedirectRedirectToActionRedirectToRoute 等。
    例如:return RedirectToAction("Login", "Authentication"),就會把 Request 轉向到 AuthenticationController 的 Login()
  • Formatted Response
    回應時指定 Content-Type。Web API 的回傳通常都用這種方式,序列化物件順便標註 Content-Type
    例如:return Json(user),會將物件序列化成 JSON 字串,並在 HTTP Headers 帶上 Content-Type=application/json

View

View 跟 Controller 有相互的對應關係,預設在 Controller 使用 View 方法回傳結果,會從以下目錄尋找對應的 *.cshtml

  1. Views\{ControllerName}\{ActionName}.cshtml
    尋找與 Controller 同名的子目錄,再找到與 Action 同名的 *.cshtml
    如上例 HomeController.Index(),就會找專案目錄下的 Views\Home\Index.cshtml 檔案。
  2. Views\Shared\{ActionName}.cshtml
    如果 Controller 同名的子目錄,找不到 Action 同名的 *.cshtml。就會到 Shared 目錄找。 如上例 HomeController.Index(),就會找專案目錄下的 Views\Shared\Index.cshtml 檔案。

Views\Home\Index.cshtml

1
2
3
@model MyWebsite.Models.UserModel

Hello~ 我是 @Model.Name

在 *.cshtml 用 @model 綁定 Model 的型別,才可以使用 @Model 取得 Controller 傳入的物件。

範例結果

[鐵人賽 Day06] ASP.NET Core 2 系列 - MVC - 範例結果

資料流動畫如下:

[鐵人賽 Day06] ASP.NET Core 2 系列 - MVC - 資料流

參考

Overview of ASP.NET Core MVC
ASP.NET Core – Setup MVC