從頭編寫 asp.net core 2.0 web api 基礎框架 (4) EF配置

solenovex發表於2017-10-13

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

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

第3部分:http://www.cnblogs.com/cgzl/p/7652413.html

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

前三部分弄完,我們已經可以對記憶體資料進行CRUD的基本操作,並且可以在asp.net core 2中整合Nlog了。

下面繼續:

Entity Framework Core 2.0

Entity Framework 是ORM(Object-Relational-Mapping)。ORM是一種讓你可以使用物件導向的正規化對資料庫進行查詢和操作。

簡單的情況下,ORM可以把資料庫中的表和Model物件一一對映起來;也有比較複雜的情況,ORM允許使用OO(物件導向)功能來做對映,例如:Person作為基類,Employee作為Person的派生類,他們倆可以在資料庫中對映成一個表;或者在沒有繼承的情況下,資料庫中的一個表可能和多個類有對映關係。

EF Core 不是 EF6的升級版,這個大家應該知道,EF Core是輕量級、具有很好的擴充套件性的,並且是跨平臺的EF版本。

EF Core 目前有很多Providers,所以支援很多種資料庫,包括:MSSQL,SQLite,SQL Compact,Postgres,MySql,DB2等等。而且還有一個記憶體的Provider,用於測試和開發。開發UWP應用的時候也可以使用EF Core(用SQLite Provider)。

EF Core支援兩種模式:

Code First:簡單理解為 先寫C#(Model),然後生成資料庫。

Database First:現在資料庫中建立表,然後生成C#的Model。

由於用asp.net core 2.0開發的專案基本都是新專案,所以建議使用Code First。

建立 Entity

Entity就是普通的C#類,就像Dto一樣。Dto是與外界打交道的Model,entity則不一樣,有一些Dto的計算屬性我們並不像儲存在資料庫中,所以entity中沒有這些屬性;而資料從entity傳遞到Dto後某些屬性也會和資料庫裡面的形式不一樣。

首先把我們原來的Product和Material這兩個Dto的名字重構一下,改成ProductDto和MaterialDto。

建立一個Entities資料夾,在裡面建立Product.cs:

namespace CoreBackend.Api.Entities
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public float Price { get; set; }
    }
}

DbContext

EFCore使用一個DbContext和資料庫打交道,它代表著和資料庫之間的一個Session,可以用來查詢和儲存我們的entities。

DbContext需要一個Provider,以便能訪問資料庫(這裡我們就用LocalDB吧)。

我們就建立一個DbContext吧(大一點的專案會使用多個DbContext)。建立MyContext並整合DbContext:

namespace CoreBackend.Api.Entities
{
    public class MyContext : DbContext
    {
        public DbSet<Product> Products { get; set; }
    }
}

這裡我們為Product建立了一個型別為DbSet<T>的屬性,它可以用來查詢和儲存例項(針對DbSet的Linq查詢語句將會被解釋成針對資料庫的查詢語句)。

因為我們需要使用這個MyContext,所以就需要先在Container中註冊它,然後就可以在依賴注入中使用了。

開啟Startup.cs,修改ConfigureServices,新增這一句話:

services.AddDbContext<MyContext>();

使用AddDbContext這個Extension method為MyContext在Container中進行註冊,它預設的生命週期使Scoped。

但是它如何連線資料庫?這就需要連線字串,我們需要為DbContext提供連線字串,這裡有兩種方式。

第一種是在MyContext中override OnConfiguring這個方法:

namespace CoreBackend.Api.Entities
{
    public class MyContext : DbContext
    {
        public DbSet<Product> Products { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer("xxxx connection string");
            base.OnConfiguring(optionsBuilder);
        }
    }
}

其中的引數optionsBuilder提供了一個UseSqlServer()這個方法,它告訴Dbcontext將會被用來連線Sql Server資料庫,在這裡就可以提供連線字串,這就是第一種方法。

第二種方法:

先大概看一下DbContext的原始碼的定義:

namespace Microsoft.EntityFrameworkCore
{
    public class DbContext : IDisposable, IInfrastructure<IServiceProvider>, IDbContextDependencies, IDbSetCache, IDbContextPoolable
    {
        public DbContext([NotNullAttribute] DbContextOptions options);

有一個Constructor帶有一個DbContextOptions引數,那我們就在MyContext種建立一個Constructor,並overload這個帶有引數的Constructor。

namespace CoreBackend.Api.Entities
{
    public class MyContext : DbContext
    {
        public MyContext(DbContextOptions<MyContext> options)
            :base(options)
        {
            
        }

        public DbSet<Product> Products { get; set; }
    }
}

這種方法相對第一種的優點是:它可以在我們註冊MyContext的時候就提供options,顯然這樣做比第一種override OnConfiguring更合理。

然後返回Startup:

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
#if DEBUG
            services.AddTransient<IMailService, LocalMailService>();
#else
            services.AddTransient<IMailService, CloudMailService>();
#endif
            var connectionString = @"Server=(localdb)\MSSQLLocalDB;Database=ProductDB;Trusted_Connection=True";
            services.AddDbContext<MyContext>(o => o.UseSqlServer(connectionString));
        }

使用AddDbContext的另一個overload的方法,它可以帶一個引數,在裡面呼叫UseSqlServer。

關於連線字串,我是用的是LocalDb,例項名是MSSQLLocalDB。可以在命令列查詢本機LocalDb的例項,使用sqllocaldb info:

也可以通過VS的Sql Server Object Explorer檢視:

連線字串中的ProductDb是資料庫名;連線字串的最後一部分表示這是一個受信任的連線,也就是說使用了整合驗證,在windows系統就是指windows憑證。

生成資料庫

因為我們使用的是Code First,所以如果還沒有資料庫的話,它應該會自動建立一個資料庫。

開啟MyContext:

        public MyContext(DbContextOptions<MyContext> options)
            :base(options)
        {
            Database.EnsureCreated();
        }

這個Constructor在被依賴注入的時候會被呼叫,在裡面寫Database.EnsureCreated()。其中Database是DbContext的一個屬性物件。

EnsureCreated()的作用是,如果有資料庫存在,那麼什麼也不會發生。但是如果沒有,那麼就會建立一個資料庫。

但是現在就執行的話,並不會建立資料庫,因為沒有建立MyContext的例項,也就不會呼叫Constructor裡面的內容。

那我們就建立一個臨時的Controller,然後注入MyContext,此時就呼叫了MyContext的Constructor:

namespace CoreBackend.Api.Controllers
{
    [Route("api/[controller]")]
    public class TestController: Controller
    {
        private MyContext _context;

        public TestController(MyContext context)
        {
            _context = context;
        }

        [HttpGet]
        public IActionResult Get()
        {
            return Ok();
        }
    }
}

使用Postman訪問Get這個Action後,我們可以從Debug視窗看見一些建立資料庫和表的Sql語句:

然後我們檢視一下Sql Server Object Explorer:

我們可以看到資料庫建立好了,裡面還有dbo.Products這個表。

Database.EnsureCreated()確實可以保證建立資料庫,但是隨著程式碼不斷被編寫,我們的Model不斷再改變,資料庫應該也隨之改變,而EnsureCreated()就不夠了,這就需要遷移(Migration

不過遷移之前,我們先看看Product這個表的具體欄位屬性:

Product的Id作為了主鍵,而Name這個字串的長度是max,而Price沒有精度限制,這樣不行。我們需要對Model生成的表的欄位進行限制!

解釋一下:Product這個entity中的Id,根據約定(Id或者ProductId)會被視為對映表的主鍵,並且該主鍵是自增的。

如果不使用Id或者ProductId這兩個名字作為主鍵的話,我們可以通過兩種方式把該屬性設定成為主鍵:Data Annotation註解和Fluet Api。我只在早期使用Data Annotation,後來一直使用Fluent Api,所以我這裡只介紹Fluent Api吧。

Fluet Api

針對Product這個entity,我們要把它對映成一個資料庫的表,所以針對每個屬性,可能需要設定一些限制,例如最大長度,是否必填等等。

針對Product,我們可以在MyContext裡面override OnModelCreating這個方法,然後這樣寫:

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Product>().HasKey(x => x.Id);
            modelBuilder.Entity<Product>().Property(x => x.Name).IsRequired().HasMaxLength(50);
            modelBuilder.Entity<Product>().Property(x => x.Price).HasColumnType("decimal(8,2)");
        }

第一行表示設定Id為主鍵(其實我們並不需要這麼做)。然後Name屬性是必填的,而且最大長度是50。最後Price的精度是8,2,資料庫裡的型別為decimal。

fluent api有很多方法,具體請檢視文件:https://docs.microsoft.com/en-us/ef/core/modeling/

然後,我們就會發現一個嚴重的問題。如果專案裡面有很多entity,那麼所有的fluent api配置都需要寫在OnModelCreating這個方法裡,那太多了。

所以我們改進一下,使用IEntityTypeConfiguration<T>。建立一個叫ProductConfiguration的類:

    public class ProductConfiguration : IEntityTypeConfiguration<Product>
    {
        public void Configure(EntityTypeBuilder<Product> builder)
        {
            builder.HasKey(x => x.Id);
            builder.Property(x => x.Name).IsRequired().HasMaxLength(50);
            builder.Property(x => x.Price).HasColumnType("decimal(8,2)");
        }
    }

把剛才在MyContext裡寫的配置都移動到這裡,然後修改一些MyContext的OnModelCreating方法:

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.ApplyConfiguration(new ProductConfiguration());
        }

就是把ProductConfiguration裡面寫的配置載入進來,和之前的效果是一樣的。

但是專案中如果有很多entities的話也需要寫很多行程式碼,更好的做法是寫一個方法,可以載入所有實現了IEntityTypeConfiguration<T>的實現類。在老版的asp.net web api 2.2裡面有一個方法可以從某個Assembly載入所有繼承於EntityTypeConfiguration的類,但是entity framework core並沒有提供類似的方法,以後我們自己寫一個吧,現在先這樣。

然後把資料庫刪掉,重新生成一下資料庫:

很好!

遷移 Migration

隨著程式碼的更改,資料庫也會跟著變,所有EnsureCreated()不滿足要求。migration就允許我們把資料庫從一個版本升級到另一個版本。那我們就研究一下,首先把資料庫刪了,然後建立第一個遷移版本。

開啟Package Manager Console,做個遷移 Add-Migration xxx:

Add-Migration 然後接著是一個你起的名字。

然後看一下VS的Solution Explorer 會發現生成了一個Migrations目錄:

裡面有兩個檔案,一個是Snapshot,它是目前entity的狀態:

namespace CoreBackend.Api.Migrations
{
    [DbContext(typeof(MyContext))]
    partial class MyContextModelSnapshot : ModelSnapshot
    {
        protected override void BuildModel(ModelBuilder modelBuilder)
        {
#pragma warning disable 612, 618
            modelBuilder
                .HasAnnotation("ProductVersion", "2.0.0-rtm-26452")
                .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);

            modelBuilder.Entity("CoreBackend.Api.Entities.Product", b =>
                {
                    b.Property<int>("Id")
                        .ValueGeneratedOnAdd();

                    b.Property<string>("Name")
                        .IsRequired()
                        .HasMaxLength(50);

                    b.Property<float>("Price")
                        .HasColumnType("decimal(8,2)");

                    b.HasKey("Id");

                    b.ToTable("Products");
                });
#pragma warning restore 612, 618
        }
    }
}

這就是當前Product這個Model的狀態細節,包括我們通過Fluent Api為其新增的對映限制等。

另一個檔案是xxxx_ProductInfoDbInitialMigration,下劃線後邊的部分就是剛才Add-Migration命令後邊跟著的名字引數。

namespace CoreBackend.Api.Migrations
{
    public partial class ProductInfoDbInitialMigration : Migration
    {
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.CreateTable(
                name: "Products",
                columns: table => new
                {
                    Id = table.Column<int>(type: "int", nullable: false)
                        .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn),
                    Name = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
                    Price = table.Column<float>(type: "decimal(8,2)", nullable: false)
                },
                constraints: table =>
                {
                    table.PrimaryKey("PK_Products", x => x.Id);
                });
        }

        protected override void Down(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.DropTable(
                name: "Products");
        }
    }
}

這裡麵包含著migration builder需要的程式碼,用來遷移這個版本的資料庫。裡面有Up方法,就是從當前版本升級到下一個版本;還有Down方法,就是從下一個版本再退回到當前版本。

我們也可以不使用 Add-Migration命令,手寫上面這些程式碼也行,我感覺還是算了吧。

另外還有一件事,那就是要保證遷移migration都有效的應用於資料庫了,那就是另一個命令 Update-Database

先等一下,我們也可以使用程式碼來達到同樣的目的,開啟MyContext:

        public MyContext(DbContextOptions<MyContext> options)
            : base(options)
        {
            Database.Migrate();
        }

把之前的EnsureCreated改成Database.Migrate(); 如果資料庫還沒刪除,那就最後刪除一次。

執行,併除法TestController:

然後會看見Product表,除此之外還有一個__EFMigrationHistory表,看看有啥:

這個表裡面儲存了哪些遷移已經被應用於這個資料庫了。這也保證了Database.Migrate()或者Update-database命令不會執行重複的遷移migration。

我們再弄個遷移,為Product新增一個屬性:

namespace CoreBackend.Api.Entities
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public float Price { get; set; }
        public string Description { get; set; }
    }

    public class ProductConfiguration : IEntityTypeConfiguration<Product>
    {
        public void Configure(EntityTypeBuilder<Product> builder)
        {
            builder.HasKey(x => x.Id);
            builder.Property(x => x.Name).IsRequired().HasMaxLength(50);
            builder.Property(x => x.Price).HasColumnType("decimal(8,2)");
            builder.Property(x => x.Description).HasMaxLength(200);
        }
    }
}

執行Add-Migration後,會在Migrations目錄生成了一個新的檔案:

namespace CoreBackend.Api.Migrations
{
    public partial class AddDescriptionToProduct : Migration
    {
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.AddColumn<string>(
                name: "Description",
                table: "Products",
                type: "nvarchar(200)",
                maxLength: 200,
                nullable: true);
        }

        protected override void Down(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.DropColumn(
                name: "Description",
                table: "Products");
        }
    }
}

然後這次執行Update-Database命令:

加上verbose引數就是顯示執行過程的明細而已。

不用執行,看看資料庫:

Description被新增上了,然後看看遷移表:

目前差不太多了,但還有一個安全隱患。它是:

如何安全的儲存敏感的配置資料,例如:連線字串

儲存連線字串,你可能會想到appSettings.json,但這不是一個好的想法。在本地開發的時候還沒有什麼問題(使用的是整合驗證),但是你要部署到伺服器的時候,資料庫連線字串可能包括使用者名稱和密碼(Sql Server的另一種驗證方式)。加入你不小心把appSettings.json或寫到C#裡面的連線字串程式碼提交到了Git或TFS,那麼這個使用者名稱和密碼包括伺服器的名稱可能就被暴露了,這樣做很不安全。

我們可以這樣做,首先針對開發環境(development environment)把C#程式碼中的連線字串拿掉,把它放到appSettings.json裡面。然後針對正式生產環境(production environment),我們使用環境變數來儲存這些敏感資料。

開發環境:

appSettings.json:

{
  "mailSettings": {
    "mailToAddress": "admin__json@qq.com",
    "mailFromAddress": "noreply__json@qq.com"
  },
  "connectionStrings": {
    "productionInfoDbConnectionString": "Server=(localdb)\\MSSQLLocalDB;Database=ProductDB;Trusted_Connection=True"
  } 
}

Startup.cs:

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
#if DEBUG
            services.AddTransient<IMailService, LocalMailService>();
#else
            services.AddTransient<IMailService, CloudMailService>();
#endif
            var connectionString = Configuration["connectionStrings:productionInfoDbConnectionString"];
            services.AddDbContext<MyContext>(o => o.UseSqlServer(connectionString));
        }

然後你可以設斷點看看connectionString的值。目前專案的環境變數是Production,先改成Development:

然後斷點除錯:

可以看到這兩個JsonConfigurationProvider就是appSettings的兩個檔案的配置。

這個就是appSettings.json,裡面包含著我們剛才新增的連線字串。

由於當前是Development環境,所以如果你檢視另外一個JsonConfigurationProvider的話,會發現它裡面的值是空的(Data數是0).

所以沒有問題。

生產環境:

在專案的屬性--Debug裡面,我們看到了環境變數:

而這個環境變數,我們可以在程式中讀取出來,所以可以在這裡新增連線字串:

注意它的key,要和appSettings.json裡面的整體結構一致;Value呢應該是給一個伺服器的資料庫的字串,這裡就隨便弄個假的吧。別忘了把Development改成Production。

然後除錯一下:

沒錯。如果你仔細除錯一下看看的話:就會從EnvironmentVariablesConfigurationProvider的第64個找到我們剛才寫到連線字串:

但是還沒完。

開啟專案的launchSettings.json:

你會發現:

{
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:60835/",
      "sslPort": 0
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "connectionStrings:productionInfoDbConnectionString": "Server=.;Database=ProductDB;UserId=sa;Password=pass;",
        "ASPNETCORE_ENVIRONMENT": "Production"
      }
    },
    "CoreBackend.Api": {
      "commandName": "Project",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      },
      "applicationUrl": "http://localhost:60836/"
    }
  }
}

連線字串在這裡。這個檔案一般都會原始碼控制給忽略,也不會在釋出的時候釋出到伺服器。那麼伺服器怎麼讀取到這個連線字串呢???

看上面除錯EnvironmentVariablesConfigurationProvider的值,會發現裡面有幾十個變數,這些基本都不是來自launchSettings.json,它們是從系統層面上定義的!!

這回我們這樣操作:

把launchSettings裡面的連線字串去掉:

{
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:60835/",
      "sslPort": 0
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {"ASPNETCORE_ENVIRONMENT": "Production"
      }
    },
    "CoreBackend.Api": {
      "commandName": "Project",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      },
      "applicationUrl": "http://localhost:60836/"
    }
  }
}

然後這裡自然也就沒有了:

現在任何json檔案都沒有敏感資訊了。

現在我們要把連線字串新增到系統變數中。

在win10搜尋框輸入 envi:

然後點選上面的結果:

點選環境變數:

這裡面上邊是使用者的變數,下面是系統的變數,這就是剛才EnvironmentVariableConfigurationProvider裡面除錯出來的那一堆環境變數。

而這個地方就是在你應該伺服器上新增連線字串的地方。再看一下除錯:

Environment的Provider在第4個位置,appSettings.production.json的在第3個位置。也就是說如果appSettings.Product.json和系統環境變數都有一樣Key的連線字串,那麼程式會選擇系統環境變數的值,因為它是後邊的配置會覆蓋前邊的配置。

在系統環境變數中新增:

然後除錯執行(需要重啟VS,以便新新增的系統環境變數生效):

嗯,沒問題!

種子資料 Seed Data

目前EF Core還沒有內建的方法來做種子資料。那麼自己寫:

建立一個MyContextExtensions.cs:

namespace CoreBackend.Api.Entities
{
    public static class MyContextExtensions
    {
        public static void EnsureSeedDataForContext(this MyContext context)
        {
            if (context.Products.Any())
            {
                return;
            }
            var products = new List<Product>
            {
                new Product
                {
                    Name = "牛奶",
                    Price = 2.5f,
                    Description = "這是牛奶啊"
                },
                new Product
                {
                    Name = "麵包",
                    Price = 4.5f,
                    Description = "這是麵包啊"
                },
                new Product
                {
                    Name = "啤酒",
                    Price = 7.5f,
                    Description = "這是啤酒啊"
                }
            };
            context.Products.AddRange(products);
            context.SaveChanges();
        }
    }
}

這是個Extension method,如果資料庫沒有資料,那就弄點種子資料,AddRange可以新增批量資料到Context(被Context追蹤),但是到這還沒有插入到資料庫。使用SaveChanges會把資料儲存到資料庫。

然後再Startup的Configure方法中呼叫這個method:

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory,
            MyContext myContext)
        {
            // loggerFactory.AddProvider(new NLogLoggerProvider());
            loggerFactory.AddNLog();

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler();
            }

            myContext.EnsureSeedDataForContext();

            app.UseStatusCodePages();

            app.UseMvc();
        }

首先注入MyContext,然後呼叫這個extension method。

然後把系統環境變數中的連線字串刪了把,並且把專案屬性Debug中改成Development,這時候需要重啟VS,因為一般環境變數是在軟體啟動的時候附加到其記憶體的,軟體沒關的情況下如果把系統環境變數給刪了,在軟體的記憶體中應該還是能找到該環境變數,所以軟體得重啟才能獲取最新的環境變數們。重啟VS,並執行:

種子資料進去了!

 

先寫到這吧!!!!

相關文章