从头编写 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 = "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的配置是非常的灵活的。

posted @ 2017-10-12 07:16  yangxu-pro  阅读(16641)  评论(28编辑  收藏  举报