title: 化繁為簡:02 後端篇(一)
date: 2021-06-23
categories: Projects
keywords:


img

前言

在上一篇 化繁為簡:01 資料庫篇 中,我們制定一些資料庫的設計規範,雖然會違背了正規理念,但是就開發過程或是後續維護其實對於沒經驗或是寫 Code 不嚴謹的程式設計師(沒錯!說的就是筆者自己)來說會比較友善,接下來我們開始摧殘後端,因為筆者以自己比較常使用 ASP.NET Core 來當範例如何建構一個好 debug 的後端(Web API),因為網路上已有豐富的資源,所以我們只做重點說明。

ASP.NET Core

ASP.NET Core 比起 ASP.NET MVC 來說除了增加相依性插入(Dependency Injection)之外差別最大的大概就是增加 Pipeline 的抽換模式,如果有使用 Node.js 上的 Express 架構來開發網頁應該就會很熟悉,它主要是讓我們後端系統從接收到瀏覽器所發出的請求(Request)到資料回復(Response)過程中可以很有彈性的插入各種功能,或者是說我們可以將整個資料處理步驟分拆成多個模組來降低程式複雜度。
img

圖片來源:Microsoft 官方說明網站

一般 ASP.NET Core 專案建立後Startup.cs 會如下面結構,我們可以在 ConfigureServices 函式內將常用的提供服務的物件註冊到 服務容器(services) 內,這樣專案內的類別就可以透過建構函式插入方式取得該服務並拿來使用,預設已經註冊了 AddControllers,它會控制器(Controller)所需的服務。
Configure 函式則可以提供我們可以決定 Request 請求與 Response回復之間的處理方式,在此我們藉由插入一些中介軟體(Middleware)來達到我們期望的處理方式,要注意的是插入的先後會影響處理的順序姓
img

接下來我們來加入一些常用的服務與中介軟體:

AddDbContext

大部分的專案都來不開始用資料庫來存放資料,在 .NET 環境下我們使用它內建的 ORM 技術 Entity Framework Core 來存取資料,我們依所使用的資料庫安裝對應的套件,像筆者資料庫習慣使用 PostgresQL,就必須安裝 Npgsql.EntityFrameworkCore.PostgreSQL以及 Microsoft.EntityFrameworkCore.Design
img

如果你使用 VS Code 開發,那你可以自己在 WebAPI.csproj 內添加套件。
img
或者是透過 Nuget Package Manager GUI 這個視覺化延伸模組來安裝套件。
img
如果使用其他資料庫可參考:Database Providers 了解可以安裝哪一個 Provider 來支援。

接著我們在 appsettings.json 內建立資料庫連線字串(這裡取名為 CloudConnection)。
再來透過 SDK 工具(安裝完 .NET SDK 後可透過指令 dotnet tool install --global dotnet-ef 安裝 EF 工具)來建立資料模型,指令如下(CloudContext 指資料庫模型的名稱):
dotnet ef dbcontext scaffold Name=CloudConnection Npgsql.EntityFrameworkCore.PostgreSQL -o Models -c CloudContext -f
img

我們也可以直接使用連線參數當作參數,但是系統會發出警告,並要求我們移除(在資料模型物件的 OnConfiguring 函式內)。
dotnet ef dbcontext scaffold "Host=192.168.10.10;Database=cloud;Pooling=true;Username=postgres;Password=P@ssw0rd" Npgsql.EntityFrameworkCore.PostgreSQL -o Models -c CloudContext -f
img
後續資料庫欄位有異動時可以再透過指令更新,只是要注意的是更新前SDK會先行編譯專案,如果編譯失敗就會終止,因此要先確保程式碼的完整性,或是先將未完成的程式碼註解起來。
如果資料表從資料庫移除,SDK並不會同步移除對應的資料表模型,我們須自行手動除,這是為了避免還有存取此資料表的程式碼未調整,若直接移除資料表模型會導致整個專案出錯而無法編譯。

最後我們在 Startup.csConfigureServices 內 透過 AddDbContext 將資料模型加入到服務容器內。
img

需要時只要在建構式內加入參數,系統會自動在服務容器內搜尋,將符合的物件帶回給我們,我們只要宣告一個類別層級的痊癒變數來儲存就可以在整個類別內使用。
img

Compression

資料壓縮也是很實用的功能,因為這代表同樣的頻寬下可以同時服務更多人,有些公司會有專屬的設備或是反向代理伺服器來處理,如果沒有的話可以在程式內加上這個功能,當然要注意的是檔案類型,字型、影片、音效檔可能就沒有壓縮效益而且耗費時間也較長。

如果你沒有 SSL 憑證或是憑證由是反向代理伺服器處理,那記得將 app.UseHttpsRedirection(); 移除。

img

筆者以 Angular 程式來看,前端靜態檔案大概都剩下 1/3 不到。
img
壓縮後批次傳輸每次 2000 筆資料大約只佔 150kB(這也會跟每筆資料量的多寡有關)。
img

MemoryCache

快取也是一個很實用的功能,如果你仔細看上面批次傳輸就可以發現第一次傳2000筆資料時會停頓一下,這是因為我們第一次就將所有資料從資料庫抓取出來並且快取(Cache)起來,後須資料就直接從記憶體快取中抓取,不須經過資料庫,可以說是用空間換取時間,使用前一樣需要先註冊到服務容器內。
img

UseStaticFiles、UseDefaultFiles

UseStaticFiles 提供網站靜態檔案(例如 htmljscssimage)的存放位置,預設是在專案下的 wwwroot 資料夾,因為這些檔案一般都是可以直接存取,所以正常都會放在路由(UseRouting)之前。

img

因為筆者前端使用 Angular 開發,所以 jscss 檔案會異動比較頻繁,所以在開發階段甚至上線測試初期都會增加將取消瀏覽器快取的 Header 宣告,如果一般企業內部使用其實可以保留下來以確保用戶都使使用最新版本的系統。
而在 Angular 專案內,筆者也會修改 angular.json 設定檔,將編譯輸出路徑指定到 wwwroot 資料夾內,這樣佈署時就不會遺忘。
img

此外,我們也可以將網址的路徑指對應到指定的資料夾,像筆者的說明文件會使用 MarkDown 格式(md 檔)編寫,並存在一個 help 資料夾內,透過 UseStaticFiles 設定就可以讓前端網站直接瀏覽觀看內容。
img

因為說明文件(md 檔)正是編輯之後的異動性就不大了,所以這邊的 no-cache Header 系統上線的最後版本就會移除掉。

img

當我們的網站是靜態頁面(有實體的 html 檔)時,我們就可以用到UseDefaultFiles,透過它我們瀏覽網站時,就可以不需要輸入完整的網址(包含網頁名稱),它可以自動搜雲網站內的網頁,將符合規則的網頁回傳給瀏覽器,預設下,它會搜尋 wwwroot 內的 default.htmdefault.htmlindex.htmindex.html
img

ResponseCaching

從上述內容來看,應該不難看出筆者其實盡量避免使用用戶端(瀏覽器)快取,這不代表比這討厭它,而是筆者認為要善用它需要適當的調教,要考量的因素很多,例如:請求頻率、資料流大小、資料更新頻率,而許多因素會隨時間逐漸改變,因此筆者認為這比較適合會長期維運的專案,或是等有優化預算時再去調整,有興趣的可以參考 ASP.NET Core 中的回應快取中介軟體,我們可以透過 ResponseCaching 來設定用戶端快取。

Middleware

除了使用現成的功能,我們也可以自己實作中介軟體,從官方文件ASP.NET Core 中介軟體可以看到簡單的作法是在 Configure 內透過 app.User 來實作。
img
不過這會讓裡面的程式碼過於膨脹,比較合理的做法是透過獨立的類別來處理。

Security headers

接下來我們來時做一個 SecurityMiddleware,主要功能是幫我們在 Header 加入內容安全策略 (Content Security Policy) 來讓瀏覽器縮限存取規則,近一步提高網站安全性。

參考資料:Security headers in ASP.NET Core

首先我們建立一個 SecurityMiddleware 類別,並添加相關 Header,這邊筆者將 Content-Security-PolicyFeature-Policy 內容獨立成文字檔,接著為 IApplicationBuilder 添加一個擴充方法 UseSecurityMiddleware

{% codeblock SecurityMiddleware.cs lang:cs %}
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;

namespace WebAPI
{
public class SecurityMiddleware
{
private readonly RequestDelegate _next;
public SecurityMiddleware(
RequestDelegate next,
Microsoft.Extensions.Configuration.IConfiguration config
)
{
_next = next;
}

    public async Task Invoke(HttpContext context)
    {
        context.Response.Headers.Add("referrer-policy", new StringValues("strict-origin-when-cross-origin"));
        context.Response.Headers.Add("x-content-type-options", new StringValues("nosniff"));
        context.Response.Headers.Add("x-frame-options", new StringValues("DENY"));
        context.Response.Headers.Add("X-Permitted-Cross-Domain-Policies", new StringValues("none"));
        context.Response.Headers.Add("x-xss-protection", new StringValues("1; mode=block"));
        string fpFilePath = Path.Combine(Directory.GetCurrentDirectory(), "data", "feature_policy.txt");
        if (File.Exists(fpFilePath))
        {
            string fp = File.ReadAllText(fpFilePath);
            if (!string.IsNullOrEmpty(fp))
            {
                fp = System.Text.RegularExpressions.Regex.Replace(fp, @"[^\u001F-\u007F]+", string.Empty);
                context.Response.Headers.Add("Feature-Policy", new StringValues(fp));
            }
        }

        string cspFilePath = Path.Combine(Directory.GetCurrentDirectory(), "data", "content_security_policy.txt");
        if (File.Exists(cspFilePath))
        {
            string scp = File.ReadAllText(cspFilePath);
            if (!string.IsNullOrEmpty(scp))
            {
                scp = System.Text.RegularExpressions.Regex.Replace(scp, @"[^\u001F-\u007F]+", string.Empty);
                context.Response.Headers.Add("Content-Security-Policy", new StringValues(scp));
            }
        }
        await _next(context);
    }
}

internal static partial class Ext
{
    internal static IApplicationBuilder UseSecurityMiddleware(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<SecurityMiddleware>();
    }
}

}
{% endcodeblock %}

我們透過 Regex 將文字檔的特殊字元移除,否則在 Linux 環境運行會出問題。

content_security_policy.txt 內容如下:

{% codeblock content_security_policy.txt lang:txt %}
base-uri ‘self’;
default-src ‘self’;
child-src ‘self’;
connect-src ‘self’;
font-src ‘self’ data: fonts.gstatic.com;
form-action ‘self’;
frame-ancestors ‘self’;
frame-src ‘self’;
img-src ‘self’;
manifest-src ‘self’;
media-src ‘self’;
object-src ‘self’;
script-src ‘self’ ‘unsafe-eval’;
style-src ‘self’ ‘unsafe-inline’;
worker-src ‘self’;
upgrade-insecure-requests;
{% endcodeblock %}

feature_policy.txt 內容如下:
{% codeblock feature_policy.txt lang:txt %}
accelerometer ‘none’;
ambient-light-sensor ‘none’;
autoplay ‘none’;
battery ‘none’;
camera ‘none’;
display-capture ‘none’;
document-domain ‘none’;
encrypted-media ‘none’;
execution-while-not-rendered ‘none’;
execution-while-out-of-viewport ‘none’;
gyroscope ‘none’;
magnetometer ‘none’;
microphone ‘none’;
midi ‘none’;
navigation-override ‘none’;
payment ‘none’;
picture-in-picture ‘none’;
publickey-credentials-get ‘none’;
sync-xhr ‘none’;
usb ‘none’;
wake-lock ‘none’;
xr-spatial-tracking ‘none’;
{% endcodeblock %}

最後我們在 Startup.csConfigure 內添加 app.UseSecurityMiddleware();
img
網站建置好後(也可以直接在 wwwroot 內建立一個簡單的 index.html 網頁來測試),透過瀏覽器開啟就可以在開發者工具中看到所添加的 Header。
img

添加 Content Security Policy 時請依實際需求調整,像筆者的 Angular 專案內使用 ng2-pdf-viewer 來瀏覽 PDF 檔案,他是透過 Mozilla 所開源的 PDF.js 來處理,而因為 PDF.js 檔案較大,所以這一個 Package 其實運作時預設是透過 CDN 抓取 PDF.js 來避免專案編譯後過於肥大,筆者在客戶反映 PDF 無法預覽時才發現因為 CSP 規則不允許使用外部 js 檔造成的,解套方式一種是修該 CSP 規則,另一種就是將 PDF.js 變成網站內的資源檔,筆者選擇後者,做法如下:
設定 angular.json,將 pdfjs-dist 內的 js
img
接著在 AppComponent 內修改全域變數 pdfWorkerSrc (這是 ng2-pdf-viewer 用來設定 PDF.js 來源位置的變數),將它指定到資源檔內的 js 檔即可。
img

後記

雖然標題為 化繁為簡,但是我現在在做的事情似乎都是把簡單的事情搞複雜,但是要整個專案開發快速,那我們前置作業就要先把一些繁瑣的事情集中處理,那後續開發就可以專注在商業邏輯上,例如對於只會用 .NET 開發 Windows 視窗程式的人,你要如何讓他能夠產生即戰力,如果你由無至有的完整教完,姑且不論他的吸收速來不來的及,對於目前專案來說很容易變成一個包袱,甚至會影響專案進度。但是如果我們可以給他一個制式化的框架,並制定基本的撰寫規則,讓他專注在邏輯處理(甚至不需要理解網頁技術的概念),那他可能就可以直接在目前的專案上帶來效益。