從頭編寫 asp.net core 2.0 web api 基礎框架 (3)

solenovex發表於2017-10-12

第一部分: http://www.cnblogs.com/cgzl/p/7637250.html

第二部分:http://www.cnblogs.com/cgzl/p/7640077.html

Github原始碼地址:https://github.com/solenovex/Building-asp.net-core-2-web-api-starter-template-from-scratch

之前我介紹完了asp.net core 2.0 web api最基本的CRUD操作,接下來繼續研究:

IoC和Dependency Injection (控制反轉和依賴注入)

先舉個例子說明一下:

比如說我們的ProductController,需要使用Mylogger作為記錄日誌的服務,MyLogger是一個在設計時指定的具體的類,這就是說ProductController對MyLogger有一個依賴。MyLogger通常是在Constructor裡面new出來的。假如ProductController還依賴於很多其他的Services,當有問題發生的時候,需要替換或修改MyLogger,那麼ProductController的程式碼就需要更改了,這也違反了設計模式的原則(對修改關閉)。這樣做呢,也不利於進行單元測試,單元測試的時候無法提供一個Mock(Mock就是在測試中對於某種不易構建的物件,建立的一個虛擬的版本,以方便測試)版本的MyLogger,因為我們使用的是具體的類。而ProductController同時也控制著MyLogger的生命週期,這是緊耦合。這個時候,Ioc(Inversion of control 控制反轉)就有用了!

Ioc把為ProductController選擇某個依賴項(具有Log功能的Service)的具體實現類(MyLogger就是可能的具體實現類之一)的這項工作委託給了外部的一個元件。

那麼上面講的Ioc的這項工作是怎麼來實現的呢?那就是Depedency Injection這個設計模式。

Dependency Injection可以說是Ioc的一個特定的種類。

DI模式是使用一個特定的物件(Container 容器)來為目標類(ProductController)進行初始化並提供其所需要的依賴項(MyLogger)。Container管理者這些依賴項的生命週期。

下面舉一個典型的例子:

    public class ProductController : Controller
    {
        private ILogger<ProductController> _logger; // interface 不是具體的實現類

        public ProductController(ILogger<ProductController> logger)
        {
            _logger = logger;
        }
    。。。。。
    }

ProductController裡面需要有一個Field來保留這個依賴項,這裡就是指_logger,而_logger不是具體的實現類,它是一個interface,ProductController需要的是一個實現了ILogger<T>介面的類。

看一下Constructor的程式碼,這種叫做Constructor注入。Constructor需要一個實現了ILogger<T>介面的類的例項,不是一個具體的類,還是一個interface。Container就會為ProductController注入它的依賴項。

這樣做的最終結果就是,鬆耦合!(ProductController不必再為那些工作負責了,也和具體的實現類沒有直接聯絡了)。這時,再需要替換和修改這些依賴項的時候僅需要改非常少的程式碼或者完全不用改程式碼了。而且單元測試也可以簡單的進行了,因為這些依賴項(ILogger)都可以被實現了ILogger介面的Mock的版本來替代了。

在asp.net core裡面呢,Ioc和依賴注入是框架內建的,這點和老版本的asp.net web api 2.2不一樣,那時候我們得使用像autofac這樣的第三方庫來實現Ioc和依賴注入。

在asp.net core裡面有一些services是內建的並且已經在Container註冊了,比如說記錄日誌用的Logger。其他的services也可以在container註冊,這一般是在StartUp類裡面的ConfigureServices方法來實現的,框架級以及應用級的services都可以加進來。

下面我們就把內建的Logger服務註冊進去。

使用內建的Logger

因為Logger是asp.net core 的內建service,所以我們就不需要在ConfigureService裡面註冊了。如果是asp.net core 1.0版本的話,我們需要配置一個或者多個Logger,但是asp.net core 2.0的話就不需要做這個工作了,因為在CreateDefaultBuilder方法裡預設給配置了輸出到Console和Debug視窗的Logger。這是原始碼:

 public static IWebHostBuilder CreateDefaultBuilder(string[] args)
        {
            var builder = new WebHostBuilder()
                .UseKestrel()
                .UseContentRoot(Directory.GetCurrentDirectory())
                .ConfigureAppConfiguration((hostingContext, config) =>
                {
                    var env = hostingContext.HostingEnvironment;

                    config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
                          .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);

                    if (env.IsDevelopment())
                    {
                        var appAssembly = Assembly.Load(new AssemblyName(env.ApplicationName));
                        if (appAssembly != null)
                        {
                            config.AddUserSecrets(appAssembly, optional: true);
                        }
                    }

                    config.AddEnvironmentVariables();

                    if (args != null)
                    {
                        config.AddCommandLine(args);
                    }
                })
                .ConfigureLogging((hostingContext, logging) =>
                {
                    logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging"));
                    logging.AddConsole();
                    logging.AddDebug();
                })
                .UseIISIntegration()
                .UseDefaultServiceProvider((context, options) =>
                {
                    options.ValidateScopes = context.HostingEnvironment.IsDevelopment();
                });

            if (args != null)
            {
                builder.UseConfiguration(new ConfigurationBuilder().AddCommandLine(args).Build());
            }

            return builder;
        }
View Code

注入Logger

我們可以在ProductController裡面注入ILoggerFactory然後再建立具體的Logger。但是還有更好的方式,Container可以直接提供一個ILogger<T>的例項,這時候呢Logger就會使用T的名字作為日誌的類別:

namespace CoreBackend.Api.Controllers
{
    [Route("api/[controller]")]
    public class ProductController : Controller
    {
        private ILogger<ProductController> _logger;

        public ProductController(ILogger<ProductController> logger)
        {
            _logger = logger;
        }
......

如果通過Constructor注入的方式不可用,那麼我們也可以直接從Container請求來得到它:HttpContext.RequestServices.GetService(typeof(ILogger<ProductController>)); 如果你在Constructor寫這句話可能會空指標,因為這個時候HttpContext應該是null吧。

不過還是建議使用Constructor注入的方式!!!

然後我們記錄一些日誌把:

        [Route("{id}", Name = "GetProduct")]
        public IActionResult GetProduct(int id)
        {
            var product = ProductService.Current.Products.SingleOrDefault(x => x.Id == id);
            if (product == null)
            {
                _logger.LogInformation($"Id為{id}的產品沒有被找到..");
                return NotFound();
            }
            return Ok(product);
        }

Log記錄時一般都分幾個等級,這點我假設大家都知道吧,就不介紹了。

然後試一下:通過Postman訪問一個不存在的產品:‘/api/product/22’,然後看看Debug輸出視窗:

嗯,出現了,前邊是分類,也就是ILogger<T>裡面T的名字,然後是級別 Information,然後就是我們記錄的Log內容。

再Log一個Exception:

        [Route("{id}", Name = "GetProduct")]
        public IActionResult GetProduct(int id)
        {
            try
            {
                throw new Exception("來個異常!!!");
                var product = ProductService.Current.Products.SingleOrDefault(x => x.Id == id);
                if (product == null)
                {
                    _logger.LogInformation($"Id為{id}的產品沒有被找到..");
                    return NotFound();
                }
                return Ok(product);
            }
            catch (Exception ex)
            {
                _logger.LogCritical($"查詢Id為{id}的產品時出現了錯誤!!", ex);
                return StatusCode(500, "處理請求的時候發生了錯誤!");
            }
        }

  記錄Exception就建議使用LogCritical了,這裡需要注意的是Exception的發生就表示伺服器發生了錯誤,我們應該處理這個exception並返回500。使用StatusCode這個方法返回特定的StatusCode,然後可以加一個引數來解釋這個錯誤(這裡一般不建議返回exception的細節)。

執行試試:

OK。

Log到Debug視窗或者Console視窗還是比較方便的,但是正式生產環境中這肯定不夠用。

正式環境應該Log到檔案或者資料庫。雖然asp.net core 的log內建了記錄到Windows Event的方法,但是由於Windows Event是windows系統獨有的,所以這個方法無法跨平臺,也就不建議使用了。

官方文件上列出了這幾個建議使用的第三發Log Provider:

把這幾個Log provider註冊到asp.net core的方式幾乎是一摸一樣的,所以介紹一個就行。我們就用比較火的NLog吧。

NLog

首先通過nuget安裝Nlog: 

注意要勾上include prerelease,目前還不是正式版。

裝完之後,我們就需要為Nlog新增配置檔案了。預設情況下Nlog會在根目錄尋找一個叫做nlog.config的檔案作為配置檔案。那麼我們就手動改新增一個nlog.config:

<?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">
  <targets>
    <target name="logfile" xsi:type="File" fileName="logs/${shortdate}.log" />

  </targets>
  <rules>
    <logger name="*" minlevel="Info" writeTo="logfile" />
  </rules>
</nlog>

然後設定該檔案的屬性如下:

對於Nlog的配置就不進行深入介紹了。具體請看官方文件的.net core那部分。

然後需要把Nlog整合到asp.net core,也就是把Nlog註冊到ILoggerFactory裡面。所以開啟Startup.cs,首先注入ILoggerFactory,然後對ILoggerFactory進行配置,為其註冊NLog的Provider:

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
        {
            // loggerFactory.AddProvider(new NLogLoggerProvider());
loggerFactory.AddNLog();
if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler(); } app.UseStatusCodePages(); app.UseMvc(); }

針對LoggerFactory.AddProvider()這種寫法,Nlog一個簡單的ExtensionMethod做了這個工作,就是AddNlog();

新增完NLog,其餘的程式碼都不需要改,然後我們試下:

在如圖所示的位置出現了log檔案。內容如下:

自定義Service

一個系統中可能需要很多個自定義的service,下面舉一個簡單的例子,

建立LocalMailService.cs:

namespace CoreBackend.Api.Services
{
    public class LocalMailService
    {
        private string _mailTo = "developer@qq.com";
        private string _mailFrom = "noreply@alibaba.com";

        public void Send(string subject, string msg)
        {
            Debug.WriteLine($"從{_mailFrom}給{_mailTo}通過{nameof(LocalMailService)}傳送了郵件");
        }
    }
}

使用這個Service,我們假裝在刪除Product的時候傳送郵件。

首先,我們要把這個LocalMailService註冊給Container。開啟Startup.cs進入ConfigureServices方法。這裡面有三種方法可以註冊service:AddTransient,AddScoped和AddSingleton,這些都表示service的生命週期。

transient的services是每次請求(不是指Http request)都會建立一個新的例項,它比較適合輕量級的無狀態的(Stateless)的service。

scope的services是每次http請求會建立一個例項。

singleton的在第一次請求的時候就會建立一個例項,以後也只有這一個例項,或者在ConfigureServices這段程式碼執行的時候建立唯一一個例項。

我們的LocalMailService比較適合Transient:

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
            services.AddTransient<LocalMailService>();
        }

現在呢,就可以注入LocalMailService的例項了:

 

namespace CoreBackend.Api.Controllers
{
    [Route("api/[controller]")]
    public class ProductController : Controller
    {
        private readonly ILogger<ProductController> _logger;
        private readonly LocalMailService _localMailService;

        public ProductController(
            ILogger<ProductController> logger,
            LocalMailService localMailService)
        {
            _logger = logger;
            _localMailService = localMailService;
        }
        [HttpDelete("{id}")]
        public IActionResult Delete(int id)
        {
            var model = ProductService.Current.Products.SingleOrDefault(x => x.Id == id);
            if (model == null)
            {
                return NotFound();
            }
            ProductService.Current.Products.Remove(model);
            _localMailService.Send("Product Deleted",$"Id為{id}的產品被刪除了");
            return NoContent();
        }

然後試一下:

嗯,沒問題。

但是現在的寫法並不符合DI的意圖。所以修改一下程式碼,首先新增一個interface,然後讓LocalMailService去實現它:

namespace CoreBackend.Api.Services
{
    public interface IMailService
    {
        void Send(string subject, string msg);
    }

    public class LocalMailService: IMailService
    {
        private string _mailTo = "developer@qq.com";
        private string _mailFrom = "noreply@alibaba.com";

        public void Send(string subject, string msg)
        {
            Debug.WriteLine($"從{_mailFrom}給{_mailTo}通過{nameof(LocalMailService)}傳送了郵件");
        }
    }
}

有了IMailService這個interface,Container就可以為我們提供實現了IMailService介面的不同的類了。

所以再建立一個CloudMailService:

    public class CloudMailService : IMailService
    {
        private readonly string _mailTo = "admin@qq.com";
        private readonly string _mailFrom = "noreply@alibaba.com";

        public void Send(string subject, string msg)
        {
            Debug.WriteLine($"從{_mailFrom}給{_mailTo}通過{nameof(LocalMailService)}傳送了郵件");
        }
    }

然後回到ConfigureServices方法裡面:

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
            services.AddTransient<IMailService, LocalMailService>();
        }

這句話的意思就是,當需要IMailService的一個實現的時候,Container就會提供一個LocalMailService的例項。

然後改一下ProductController:

namespace CoreBackend.Api.Controllers
{
    [Route("api/[controller]")]
    public class ProductController : Controller
    {
        private readonly ILogger<ProductController> _logger;
        private readonly IMailService _mailService;

        public ProductController(
            ILogger<ProductController> logger,
            IMailService mailService)
        {
            _logger = logger;
            _mailService = mailService;
        }

然後執行一下,效果和上面是一樣的。

然而我們註冊了LocalMailService,那麼CloudMailService是什麼時候用呢?

分兩種方式:

一、使用compiler directive

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
#if DEBUG
            services.AddTransient<IMailService, LocalMailService>();
#else
            services.AddTransient<IMailService, CloudMailService>();
#endif
        }

這樣寫就是告訴compiler,如果是Debug build的情況下,那麼就使用LocalMailService(把這句話納入編譯的範圍),如果是在Release Build的模式下,就是用CloudMailService。

那我們就切換到Release Build模式(或者在DEBUG前邊加一個歎號試試):

執行試試,居然沒起作用。隨後發現原因是這樣的:

在Release模式下Debug.WriteLine將不會被呼叫,因為這是Debug Build模式下專有的方法。。。

那我們就改一下Cloud'MailService,使用logger吧:

 public class CloudMailService : IMailService
    {
        private readonly string _mailTo = "admin@qq.com";
        private readonly string _mailFrom = "noreply@alibaba.com";
        private readonly ILogger<CloudMailService> _logger;

        public CloudMailService(ILogger<CloudMailService> logger)
        {
            _logger = logger;
        }

        public void Send(string subject, string msg)
        {
            _logger.LogInformation($"從{_mailFrom}給{_mailTo}通過{nameof(LocalMailService)}傳送了郵件");
        }
    }

然後再試一下看看結果:

這回就沒問題了。

二、是通過環境變數控制配置檔案

asp.net core 支援各式各樣的配置方法,包括使用JSON,xml, ini檔案,環境變數,命令列引數等等。建議使用的還是JSON。

建立一個appSettings.json檔案,然後把MailService相關的常量存到裡面:

{
  "mailSettings": {
    "mailToAddress": "admin__json@qq.com",
    "mailFromAddress": "noreply__json@qq.com"
  }
}

asp.net core 2.0 預設已經做了相關的配置,我們再看一下這部分的原始碼

public static IWebHostBuilder CreateDefaultBuilder(string[] args)
        {
            var builder = new WebHostBuilder()
                .UseKestrel()
                .UseContentRoot(Directory.GetCurrentDirectory())
                .ConfigureAppConfiguration((hostingContext, config) =>
                {
                    var env = hostingContext.HostingEnvironment;

                    config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
                          .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);

               。。。。。。return builder;
        }

紅色部分的config的型別是IConfigurationBuilder,它用來配置的。首先是要找到appSettings.json檔案,asp.net core 2.0已經做好了相關配置,它預設會從ContentRoot去找appSettings.json檔案。

然後使用AddJsonFile這個方法來新增Json配置檔案,第一個引數是檔名;第二個引數optional表示這個配置檔案是否是可選的,把它設定成false表示我們不必非得用這個配置檔案;第三個引數reloadOnChange為true,表示如果執行的時候配置檔案變化了,那麼就立即過載它。

使用appSettings.json裡面的值就需要使用實現了IConfiguration這個介面的物件。建議的做法是:在Startup.cs裡面注入IConfiguration(這個時候通過CreateDefaultBuilder方法,它已經建立好了),然後把它賦給一個靜態的property:

    public class Startup
    {
        public static IConfiguration Configuration { get; private set; }

        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

然後我們把LocalMailService裡面改一下:

    public class LocalMailService: IMailService
    {
        private readonly string _mailTo = Startup.Configuration["mailSettings:mailToAddress"];
        private readonly string _mailFrom = Startup.Configuration["mailSettings:mailFromAddress"];

        public void Send(string subject, string msg)
        {
            Debug.WriteLine($"從{_mailFrom}給{_mailTo}通過{nameof(LocalMailService)}傳送了郵件");
        }
    }

通過剛才寫的Startup.Configuration來訪問json配置檔案中的變數,根據json檔案中的層次結構,第一層物件我們取的是mailSettings,然後試mailToAddress和mailFromAddress,他們之間用冒號分開,表示它們的層次結構。

通過這種方法取得到的值都是字串。

然後執行一下試試,別忘了把Build模式改成Debug:

嗯,沒問題。

針對不同環境選擇不同json配置檔案裡的值(不是選擇檔案,而是值)

針對不同的環境選擇不同的JSON配置檔案,要求這個檔案的名字的一部分包含有環境的名稱。

新增一個Production環境下的配置檔案:appSettings.Production.json, 其中Production是環境的名稱,在專案--屬性--Debug 裡面環境變數的值:

建立好appSettings.Production.json後,可以發現它被作為appSettings.json的一個子檔案顯示出來,這樣很好:

{
  "mailSettings": {
    "mailToAddress": "admin__Production@qq.com"
  }
}

再看一下這部分的原始碼:

config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
      .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);

AddJsonFile方法呼叫的順序非常重要,它決定了多個配置檔案的優先順序。這裡如果某個變數在appSettings和appSettings.Production.json都有,那麼appSettings.Production.json的變數會被採用,因為appSettings.Production.json檔案是後來才被呼叫的。

其中env的型別是IHostingEnvirongment,它裡面的EnvironmentName就是環境變數的名稱,如果環境變數填寫的是Production,那就是appSettings.Production.json。

這麼寫的作用就是如果是在Production環境下,那麼appSettings.json裡面的部分變數值就會被appSettings.Production.json裡面也存在的變數的值覆蓋。

試試:首先環境變數是Development:

然後改成Production,試試:

結果如預期。

綜上,通過Compiler Directive(設定Debug Build / Release Build),並結合著不同的環境變數和配置檔案,asp.net core的配置是非常的靈活的。

相關文章