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

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