Github源码地址:https://github.com/solenovex/Building-asp.net-core-2-web-api-starter-template-from-scratch
这是第一大部分的最后一小部分。要完成CRUD的操作。
我们可以直接在Controller访问DbContext,但是可能会有一些问题:
1.相关的一些代码到处重复,有可能在程序中很多地方我都会更新Product,那样的话我可能就会在多个Action里面写同样的代码,而比较好的做法是只在一个地方写更新Product的代码。
2.到处写重复代码还会导致另外一个问题,那就是容易出错。
3.还有就是难以测试,如果想对Controller的Action进行单元测试,但是这些Action还包含着持久化相关的逻辑,这就很难的精确的找出到底是逻辑出错还是持久化部分出错了。
所以如果能有一种方法可以mock持久化相关的代码,然后再测试,就会知道错误不是发生在持久化部分了,这就可以用Repository Pattern了。
Repository Pattern是一种抽象,它减少了复杂性,目标是使代码对repository的实现更安全,并且与持久化要无关。
其中持久化无关这点我要明确一下,有时候是指可以随意切换持久化的技术,但这实际上并不是repository pattern的目的,其真正的目的是可以为repository挑选一个最好的持久化技术。例如:创建一个Product最好的方式可能是使用entity framework,而查询product最好的方式可能是使用dapper,也有可能会调用外部服务,而对调用repository的消费者来说,它不关心这些具体的实现细节。
首先再建立一个Material entity,然后和Product做成多对一的关系:
namespace CoreBackend.Api.Entities { public class Material { public int Id { get; set; } public int ProductId { get; set; } public string Name { get; set; } public Product Product { get; set; } } public class MaterialConfiguration : IEntityTypeConfiguration<Material> { public void Configure(EntityTypeBuilder<Material> builder) { builder.HasKey(x => x.Id); builder.Property(x => x.Name).IsRequired().HasMaxLength(50); builder.HasOne(x => x.Product).WithMany(x => x.Materials).HasForeignKey(x => x.ProductId) .OnDelete(DeleteBehavior.Cascade); } } }
修改Product.cs:
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 ICollection<Material> Materials { 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); } } }
然后别忘了在Context里面注册Material的Configuration并添加DbSet属性:
namespace CoreBackend.Api.Entities { public class MyContext : DbContext { public MyContext(DbContextOptions<MyContext> options) : base(options) { Database.Migrate(); } public DbSet<Product> Products { get; set; } public DbSet<Material> Materials { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new ProductConfiguration()); modelBuilder.ApplyConfiguration(new MaterialConfiguration()); } } }
然后添加一个迁移 Add-Migration AddMaterial:
然后数据库直接进行迁移操作了,无需再做update-database。
建立一个Repositories文件夹,添加一个IProductRepository:
namespace CoreBackend.Api.Repositories { public interface IProductRepository { IEnumerable<Product> GetProducts(); Product GetProduct(int productId, bool includeMaterials); IEnumerable<Material> GetMaterialsForProduct(int productId); Material GetMaterialForProduct(int productId, int materialId); } }
这个是ProductRepository将要实现的接口,里面定义了一些必要的方法:查询Products,查询单个Product,查询Product的Materials和查询Product下的一个Material。
其中类似GetProducts()这样的方法返回类型还是有争议的,IQueryable<T>还是IEnumerable<T>。
如果返回的是IQueryable,那么调用repository的地方还可以继续构建IQueryable,例如在真正的查询执行之前附加一个OrderBy或者Where方法。但是这样做的话,也意味着你把持久化相关的代码给泄露出去了,这看起来是违反了repository pattern的目的。
如果是IEnumerable,为了返回各种各样情况的查询结果,需要编写几十个上百个查询方法,那也是相当繁琐的,几乎是不可能的。
目前看来,两种返回方式都有人在用,所以根据情况定吧。我们的程序需求比较简单,所以使用IEnumerable。
然后建立具体的实现类 ProductRepository:
namespace CoreBackend.Api.Repositories { public class ProductRepository : IProductRepository { private readonly MyContext _myContext; public ProductRepository(MyContext myContext) { _myContext = myContext; } public IEnumerable<Product> GetProducts() { return _myContext.Products.OrderBy(x => x.Name).ToList(); } public Product GetProduct(int productId, bool includeMaterials) { if (includeMaterials) { return _myContext.Products .Include(x => x.Materials).FirstOrDefault(x => x.Id == productId); } return _myContext.Products.Find(productId); } public IEnumerable<Material> GetMaterialsForProduct(int productId) { return _myContext.Materials.Where(x => x.ProductId == productId).ToList(); } public Material GetMaterialForProduct(int productId, int materialId) { return _myContext.Materials.FirstOrDefault(x => x.ProductId == productId && x.Id == materialId); } } }
这里面要包含吃就会的逻辑,所以我们需要MyContext(也有可能需要其他的Service)那就在Constructor里面注入一个。重要的是调用的程序不关心这些细节。
这里也是编写额外的持久化逻辑的地方,比如说查询之后做个排序之类的。
(具体的Entity Framework Core的方法请查阅EF Core官方文档:https://docs.microsoft.com/en-us/ef/core/)
GetProducts,查询所有的产品并按照名称排序并返回查询结果。这里注意一定要加上ToList(),它保证了对数据库的查询就在此时此刻发生。
GetProduct,查询单个产品,判断一下是否需要把产品下面的原料都一起查询出来,如果需要的话就使用Include这个extension method。查询条件可以放在FirstOrDefault()方法里面。
GetMaterialsForProduct,查询某个产品下所有的原料。
GetMaterialForProduct,查询某个产品下的某种原料。
建立好Repository之后,需要在Startup里面进行注册:
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)); services.AddScoped<IProductRepository, ProductRepository>(); }
针对Repository,最好的生命周期是Scoped(每个请求生成一个实例)。<>里面前边是它的合约接口,后边是具体实现。
使用Repository
先为ProductDto添加一个属性:
namespace CoreBackend.Api.Dtos { public class ProductDto { public ProductDto() { Materials = new List<MaterialDto>(); } public int Id { get; set; } public string Name { get; set; } public float Price { get; set; } public string Description { get; set; } public ICollection<MaterialDto> Materials { get; set; } public int MaterialCount => Materials.Count; } }
就是返回该产品所用的原料个数。
再建立一个ProductWithoutMaterialDto:
namespace CoreBackend.Api.Dtos { public class ProductWithoutMaterialDto { public int Id { get; set; } public string Name { get; set; } public float Price { get; set; } public string Description { get; set; } } }
这个Dto不带原料相关的导航属性。
然后修改controller。
现在我们可以使用ProductRepository替代原来的内存数据了,首先在ProductController里面注入ProductRepository:
public class ProductController : Controller { private readonly ILogger<ProductController> _logger; private readonly IMailService _mailService; private readonly IProductRepository _productRepository; public ProductController( ILogger<ProductController> logger, IMailService mailService, IProductRepository productRepository) { _logger = logger; _mailService = mailService; _productRepository = productRepository; }
1.修改GetProducts这个Action:
[HttpGet] public IActionResult GetProducts() { var products = _productRepository.GetProducts(); var results = new List<ProductWithoutMaterialDto>(); foreach (var product in products) { results.Add(new ProductWithoutMaterialDto { Id = product.Id, Name = product.Name, Price = product.Price, Description = product.Description }); } return Ok(results); }
注意,其中的Product类型是DbContext和repository操作的类型,而不是Action应该返回的类型,而且我们的查询结果是不带Material的,所以需要把Product的list映射成ProductWithoutMaterialDto的list。
然后试试:
查询的时候报错,是因为Product的属性Price,在fluentapi里面设置的类型是decimal(8, 2),而Price的类型是float,那么我们把所有的Price的类型都改成decimal:
public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public string Description { get; set; } public ICollection<Material> Materials { get; set; } } public class ProductCreation { [Display(Name = "产品名称")] [Required(ErrorMessage = "{0}是必填项")] [StringLength(10, MinimumLength = 2, ErrorMessage = "{0}的长度应该不小于{2}, 不大于{1}")] public string Name { get; set; } [Display(Name = "价格")] [Range(0, Double.MaxValue, ErrorMessage = "{0}的值必须大于{1}")] public decimal Price { get; set; } [Display(Name = "描述")] [MaxLength(100, ErrorMessage = "{0}的长度不可以超过{1}")] public string Description { get; set; } } public class ProductDto { public ProductDto() { Materials = new List<MaterialDto>(); } public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public string Description { get; set; } public ICollection<MaterialDto> Materials { get; set; } public int MaterialCount => Materials.Count; } public class ProductModification { [Display(Name = "产品名称")] [Required(ErrorMessage = "{0}是必填项")] [StringLength(10, MinimumLength = 2, ErrorMessage = "{0}的长度应该不小于{2}, 不大于{1}")] public string Name { get; set; } [Display(Name = "价格")] [Range(0, Double.MaxValue, ErrorMessage = "{0}的值必须大于{1}")] public decimal Price { get; set; } [Display(Name = "描述")] [MaxLength(100, ErrorMessage = "{0}的长度不可以超过{1}")] public string Description { get; set; } } public class ProductWithoutMaterialDto { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public string Description { get; set; } }
还有SeedData里面和即将废弃的ProductService:
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 = new decimal(2.5), Description = "这是牛奶啊" }, new Product { Name = "面包", Price = new decimal(4.5), Description = "这是面包啊" }, new Product { Name = "啤酒", Price = new decimal(7.5), Description = "这是啤酒啊" } }; context.Products.AddRange(products); context.SaveChanges(); } } } namespace CoreBackend.Api.Services { public class ProductService { public static ProductService Current { get; } = new ProductService(); public List<ProductDto> Products { get; } private ProductService() { Products = new List<ProductDto> { new ProductDto { Id = 1, Name = "牛奶", Price = new decimal(2.5), Materials = new List<MaterialDto> { new MaterialDto { Id = 1, Name = "水" }, new MaterialDto { Id = 2, Name = "奶粉" } }, Description = "这是牛奶啊" }, new ProductDto { Id = 2, Name = "面包", Price = new decimal(4.5), Materials = new List<MaterialDto> { new MaterialDto { Id = 3, Name = "面粉" }, new MaterialDto { Id = 4, Name = "糖" } }, Description = "这是面包啊" }, new ProductDto { Id = 3, Name = "啤酒", Price = new decimal(7.5), Materials = new List<MaterialDto> { new MaterialDto { Id = 5, Name = "麦芽" }, new MaterialDto { Id = 6, Name = "地下水" } }, Description = "这是啤酒啊" } }; } } }
然后在运行试试:
结果正确。
然后修改GetProduct:
[Route("{id}", Name = "GetProduct")] public IActionResult GetProduct(int id, bool includeMaterial = false) { var product = _productRepository.GetProduct(id, includeMaterial); if (product == null) { return NotFound(); } if (includeMaterial) { var productWithMaterialResult = new ProductDto { Id = product.Id, Name = product.Name, Price = product.Price, Description = product.Description }; foreach (var material in product.Materials) { productWithMaterialResult.Materials.Add(new MaterialDto { Id = material.Id, Name = material.Name }); } return Ok(productWithMaterialResult); } var onlyProductResult = new ProductDto { Id = product.Id, Name = product.Name, Price = product.Price, Description = product.Description }; return Ok(onlyProductResult); }
首先再添加一个参数includeMaterial表示是否带着Material表的数据一起查询出来,该参数有一个默认值是false,就是请求的时候如果不带这个参数,那么这个参数的值就是false。
通过repository查询之后把Product和Material分别映射成ProductDto和MaterialDot。
试试,首先不包含Material:
目前数据库的Material表没有数据,可以手动添加几个,也可以把数据库的Product数据删了,改一下种子数据那部分代码:
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 = new decimal(2.5), Description = "这是牛奶啊", Materials = new List<Material> { new Material { Name = "水" }, new Material { Name = "奶粉" } } }, new Product { Name = "面包", Price = new decimal(4.5), Description = "这是面包啊", Materials = new List<Material> { new Material { Name = "面粉" }, new Material { Name = "糖" } } }, new Product { Name = "啤酒", Price = new decimal(7.5), Description = "这是啤酒啊", Materials = new List<Material> { new Material { Name = "麦芽" }, new Material { Name = "地下水" } } } }; context.Products.AddRange(products); context.SaveChanges(); } } }