歡迎光臨
每天分享高質量文章

ASP.NET Core 沉思錄 – 環境的思考

我的博客換新家啦,新的地址為:https://clrdaily.com 😀

今天我們來一起思考一下如何在不同的環境應用不同的配置。這裡的配置不僅僅指 IConfiguration 還包含 IWebHostBuilder 的創建過程和 Startup 的初始化過程。

0 太長不讀

  • 環境造成的差異在架構中基本體現在 Infrastructure 中的各個 Adapter 中。而不應當入侵應用程式內部
  • 在 ASP.NET Core 中我們需要考慮如何將這些 Adapter(一)放在 service collection 中 (二)(可選)添加到 pipeline 中。
  • ASP.NET Core 預設提供了一系列手段來判斷當前的環境,只不過這些手段的設計奇怪且不完整。
  • IWebHostBuilder 的配製方法大多和環境相關,但 UseSetting 和環境無關。
  • 我們應當應用開閉原則,將相同環境的配置聚合起來,不同環境的配置進行統一抽象。方便維護和擴展。
  • 當我們進行設計的時候,需要註意不要將思路局限在 Framework 的設計上,而應當切實考慮我們真正希望解決的問題。

1 架構層面的思考

Web Service 的開發和部署過程會涉及若干環境。總的來說可以分為開發環境和部署環境。而部署環境往往又分為 QA、Stage 和 Production 等。對於不同的環境,應用程式可能需要應用不同的配置或實現。還是回到架構的層面上,如下圖:

那麼這種不同應該體現在架構的哪一個層面上呢?應當讓這些不同體現在 Infrastructure 的那些 Adapters 上。因為 Adapter 是其中直接和環境相關的部分。

用一個典型的例子來表示。假定一個註冊用戶 Account 的業務。在 Application Service 層面,我們提供瞭如下的接口:

public class AccountRegistrationService {
    public AccountRegistrationResult Register(AccountRegistrationRequest request{
        Account account = this.repository.CreateDetached();
        // initialize account from request
        account.Save();
        return AccountRegistrationResult.Create(account);
    }
}

在 Domain 層面我們有代表領域物件 Account 的型別 AccountAccount 型別的 Save() 方法可以儲存賬戶信息,其中的實現類似:

public class Account {
    ...

    public void Save() {
        this.repository.Save(this);
    }
}

而其中的 repository 則依賴 UnitOfWork 而 UnitOfWork 則可能依賴於具體的持久化實現或者依賴於其他遠程服務:

public class AccountRepository {
    readonly IUnitOfWork session;

    public Account CreateDetached() {
        return new Account(this);
    }

    public void Save(Account account{
        this.session.RegisterNew(account);
    }
}

在這個例子中,AccountService 屬於 Application Service 層面,AccountAccountRepository 則屬於 Domain 層面。這兩層的依賴關係是 Application Service 依賴於 Domain。而 Domain 中的 UnitOfWork 則是一個接口。假設我們需要將資料寫入資料庫。則這個接口的實現需要持久化的支持例如它需要使用特定的 IDbConnection (Adapter)。即 IUnitOfWork 的實現位於 Infrastructure 層,併在 Infrastructure 層呼叫 Adapter 向 DB 中寫入信息。

而對於不同的環境則可以使用不同的實現,例如,對於運行單元測試的環境,我們不妨叫她 Test 環境。這個 DB 很有可能是一個 in memory 的 SQLite 資料庫。而在生產環境則是 MySQL 的集群。

應用程式的內部邏輯最終全部依賴與特定的抽象或接口。它們全部嚴密的包裹在 Infrastructure 之中,並和外部環境完全隔離。而 Infrastructure 中的 Adapter 則負責聯繫外部環境。綜上所述,環境相關的變化應當全部封閉在 Infrastructure 中。

2 ASP.NET Core 中的對應關係

ASP.NET Core 應用程式中的組件的初始化由兩個部分構成,第一個部分就是將組件中的型別添加到依賴註入的 IServiceCollection 實體中,以便進行創建;第二個部分(可選)即將組件通過 IApplicationBuilder 添加到應用程式的處理流水線中。我們一個一個來思考。

2.1 依賴註入

ASP.NET Core Web Application 中用依賴註入來決定某種抽象的實現型別。但需要指出的是 ASP.NET 應用程式的依賴註入是分兩個階段進行的。(我們將在另外一篇中介紹),簡單來說 ServiceCollection 的構建分為兩個部分:

  • 為了構建宿主環境而添加的型別;(Infrastructure 層)
  • 為了應用程式本身而添加的 Framework(例如 MvC)和各種業務型別。(Infrastructure 層,Application + Domain 層)。

而和環境相關的部分主要位於 “為了構建宿主環境而添加的型別” 中。這一部分的代碼屬於在 IStartup 初始化之前的 WebHostBuilder 構建代碼中。一般來說,我們習慣於將 UseStartup 呼叫放在 IWebHostBuilder 實體創建的最後,那麼也就是 UseStartup 之前的代碼:

public static IWebHostBuilder CreateWebHostBuilder(string[] args)
{
    return new WebHostBuilder()
        .UseKestrel()
        .ConfigureLogging(...)
        //
        // The configurations before UseStartup are environment specific
        //
        .UseStartup();
}

2.2 流水線

在流水線配置中主要考慮的是 Web 輸入輸出上的的變化。例如 Production 環境需要配置 SSL,消除敏感 Header,消除詳細的 Error Information 等等。

將組件配置到應用程式的流水線的操作是在 IStartup 接口的實現中進行的。定義 IStartup 接口實現的方式大體有兩種,第一種是呼叫 WebHostBuilderExtensions.Configure 方法,另一種是使用 WebHostBuilderExtensions.UseStartup 方法。不論使用何種方式最終都會歸結到對 IApplicationBuilder 的操作:

public void Configure(IApplicationBuilder app{
    // building pipeline
}

在這個時候,宿主初始化相關的型別已經全部可以使用了。因此取用環境相關的信息(環境型別,配置等)就更方便了。

3 落地

ASP.NET Core 對這個環節的設計很奇怪。一方面,它提供了非常底層的基於 IHostingEnvironment.EnvironmentName 的值來進行環境區分的方法。例如,官方範例中往往會使用如下的代碼:

new WebHostBuilder()
    .UseKestrel()
    .ConfigureLogging((context, logBuilder) => {
        if (context.HostingEnvironment.IsDevelopment()) {
            ...
        }
        else if (context.HostingEnvironment.IsProduction()) {
            ...
        }
        else {
            ...
        }
    })
    ...

而另一方面卻又在 Startup 上設計了命名的 Convension。例如:

class DevelopmentStartup {}     // for Development
class ProductionStartup {}      // for Production
class Startup {}                // fallback

...

webHostBuilder.UseStartup(assemblyName);

又例如:

class Startup  {
    public void ConfigureServices(IServiceCollection services{ }
    public void ConfigureStagingServices(IServiceCollection services{ }
    public void Configure(IApplicationBuilder app, IHostingEnvironment env{ }
    public void ConfigureStaging(IApplicationBuilder app, IHostingEnvironment env{ }
}

這些設計差異很大且每一個都不徹底。而在實際專案中環境屬於一個擴展點;而每一套環境的各項配置應當是內聚的。因此上述幾種方式或多或少會增加維護上的成本。而較好的設計應當針對如下三個問題:

  • 能夠立刻說出,我的系統支持幾種環境;
  • 每一種環境的各種型別的配置(例如,配置源、日誌記錄、HTTP Client、資料庫)是什麼樣子的,有什麼差異;
  • 能不能用兩步添加一個新的環境:第一,一次性創建一個新環境的所有配置,第二,將這個環境納入到系統初始化過程中。

為了達到這個要求,需要考慮統一的實現手段。

3.1 在 WebHost 開始構建之前我們並不能確定環境信息

一個最簡單的想法就是根據不同的環境採取兩種完全不同的 WebHostBuilder 配置流程。例如:

WebHostBuilder builder = new WebHostBuilderFactory().Create(env.EnvironmentName);

遺憾的是這種設計本身是有問題的。首先,若干環節都可以影響環境的最終確定,包括:

  • 當前 Session 的 ASPNETCORE_ENVIRONMENT 的值;(請參見 https://github.com/aspnet/AspNetCore/blob/master/src/Hosting/Hosting/src/WebHostBuilder.cs#L44)
  • Properties/launchSettings.json 中選定 Profile 中 ASPNETCORE_ENVIRONMENT 的值(如果用 dotnet run 命令執行的話)
  • WebHostBuilder.UseEnvironment(name) 的引數值;
  • WebHostBuilder.UseSetting(key, value)keyWebHostDefaults.EnvironmentKey 時的值。
  • 若 Host 在 IIS 中,則 web.config 中關於 environmentVariable 的設置。

因此只有在 WebHostBuilder 開始 Build 時,我們才可以最終確定環境名稱。

3.2 `UseSetting` 並不是環境相關的

另一種方案是包裝 IWebHostBuilder 使其能夠依據環境做出相應的 Dispatch。例如:

abstract class EnvironmentAwareWebHostBuilder : IWebHostBuilder {
    IWebHostBuilder UnderlyingBuilder { get; }
    protected abstract bool IsSupported(IHostingEnvironment hostingEnvironment);

    protected EnvironmentAwareWebHostBuilder(IWebHostBuilder underlyingBuilder)
    {
        // Validation omitted
        UnderlyingBuilder = underlyingBuilder;
    }

    // ...
}

從而我們可以分別為不同的環境進行相應的配置。以 ConfigureService 方法為例:

public IWebHostBuilder ConfigureServices(Action configureServices)
{
    UnderlyingBuilder.ConfigureServices(
        (context, services) =>
        {
            if (!IsSupported(context.HostingEnvironment)) { return; }

            configureServices(services);
        });
    return this;
}

按照上述方式包裝 ConfigureAppConfiguration,這樣就可以構造以下的擴展方法:

public static IWebHostBuilder UseEnvironment(
    this IWebHostBuilder builder,
    string environmentName, 
    Action configureBuilder)
{
    bool IsEnvironmentSupported(IHostingEnvironment h=> 
        h.IsEnvironment(config.environmentName);

    EnvironmentAwareWebHostBuilder environmentAwareBuilder =
        new DelegatedWebHostBuilder(builder, IsEnvironmentSupported);
    config.configureBuilder(environmentAwareBuilder);

    return builder;
}

這種方案下的 WebHostBuilder 初始化邏輯就變成了:

webHostBuilder
    .UseEnvironment("Development", wb => {
        wb
            .ConfigureService((ctx, cb) => { ... })
            .ConfigureLogging((lb) => { ... })
            ...
    })
    .UseEnvironment("Production", wb => {
        // configure for production
    });

這樣我們至少就可以用若干擴展方法類將不同環境完全分開了。但是這個實現方案是有問題的:UseSetting 方法。IWebHostBuilder 所公開的方法中除了 BuildConfigureServicesConfigureAppConfiguration 之外還有第四個方法:UseSetting。和上述 ConfigureXxx 方法不同,UseSetting 方法執行完畢之後其影響馬上生效,而且該方法無法根據不同的環境作出變化。即,如果我們使用了:

webHostBuilder
    .UseEnvironment("Development", wb => wb.UseSetting("Foo""Bar"))
    .UseEnvironment("Production", wb => wb.UseSetting("Foo""O_o"));

且當前環境為 DevelopmentIConfiguration 實體的 "Foo" 對應的值為 "O_o"。這就會造成混淆。

3.3 還是從擴展點來思考

從第 2 節的論述中我們已經知道和環境相關的配置可能存在於宿主環境初始化過程中,也可能存在 Startup 初始化過程中(即 WebHost.Run 方法執行過程中)。因此我們必須綜合考慮這兩個部分,但是這個兩個部分天生是不同的。那麼強行進行統一也是不合適的。

根據開閉原則,我們還是應該從擴展點上來考慮。首先我們能夠確定我們的 Adapter 有哪些。又有哪一些 Adapter 是和環境相關的。例如我們和環境相關的 Adapter 有 DB,配置檔案加載,日誌記錄,HttpClient(在非 Development 環境中我們可能需要進行客戶端證書驗證),在流水線創建過程中需要根據環境配置是否需要 HTTPS 強制跳轉,需要配置錯誤信息的詳細程度等等。在梳理好這些內容後我們就能有針對性的創建方法對各個部分進行配置了,我們可以使用工廠樣式:

class WebHostConfigureFactory {
    ...

    public IWebHostConfigurator Create(string environmentName{
        return cachedConfigurators[environmentName];
    }
}

而每一個 IWebHostConfigurator 中都包含了所有的環境相關配置:

interface IWebHostConfigurator {
    void AddDatabase(IHostingEnvironment environment, IServiceCollection services);
    void LoadConfiguration(IHostingEnvironment environment, IConfigurationBuilder configBuilder);
    void ConfigureLogging(IHostingEnvironment environment, ILoggingBuilder loggingBuilder);
    void AddHttpClient(IHostingEnvironment environment, IServiceCollection services);
    void ConfigureHttpsRedirection(IHostingEnvironment environment, IConfiguration configuration, IApplicationBuilder builder);
    void ConfigureErrorHandler(IHostingEnvironment environment,  IConfiguration configuration, IApplicationBuilder builder);
}

而這樣我們為各個環境的擴展點建立了抽象,從而統一配置過程:

static IWebHostBuilder CreateWebHostBuilder() {
    return new WebHostBuilder()
        .UseKestrel()
        //
        // Common configurations
        //
        .ConfigureServices((context, services) => {
            IWebHostConfigurator configurator = factory.Create(context.HostingEnvironment.EnvironmentName);

            configurator.AddDatabase(context.HostingEnvironment, services);
            configurator.AddHttpClient(context.HostingEnvironment, services);
        })
        .ConfigureLogging((context, logBuilder) => {
            factory
                .Create(context.HostingEnvironment.EnvironmentName)
                .ConfigureLogging(context.HostingEnvironment, logBuilder);
        })
        .UseStartup();
}

...

class Startup {
    ...

    public void Configure(IApplicationBuilder app{
        IWebHostConfigurator configurator = factory.Create(hostingEnvironment.EnvironmentName);
        configurator.ConfigureHttpsRedirection(hostingEnvironment, configuration, app);
        configurator.ConfigureErrorHandler(hostingEnvironment, configuration, app);

        // Common configurations
    }
}

4 總結

請跳到文章開頭 😀

參考資料

  • R. C. Martin and R. C. Martin, Clean architecture: a craftsman’s guide to software structure and design. London, England: Prentice Hall, 2018.
  • Unit-of-work: https://martinfowler.com/eaaCatalog/unitOfWork.html
  • Dependency Injection in ASP.NET Core: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-2.2
  • App startup in ASP.NET Core: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/startup?view=aspnetcore-2.2
  • Use multiple environments in ASP.NET Core: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/environments?view=aspnetcore-2.2

如果您覺得本文對您有幫助,也歡迎分享給其他的人。我們一起進步。歡迎關註我的微信公眾號:

    赞(0)

    分享創造快樂