從頭編寫 asp.net core 2.0 web api 基礎框架 (3)
第一部分: 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 = "[email protected]"; private string _mailFrom = "[email protected]"; 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 = "[email protected]"; private string _mailFrom = "[email protected]"; 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 = "[email protected]"; private readonly string _mailFrom = "[email protected]"; 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 = "[email protected]"; private readonly string _mailFrom = "[email protected]"; 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": "[email protected]", "mailFromAddress": "[email protected]" } }
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": "[email protected]" } }
再看一下這部分的源碼:
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的配置是非常的靈活的。
從頭編寫 asp.net core 2.0 web api 基礎框架 (3)