天天看點

EFCore:關于DDD中值對象(Owns)無法更新數值

  最近使用DDD+EFCore時,使用EFCore提供的OwnsOne或者OwnsMany關聯值對象儲存資料,沒想到遇到一個很奇怪的問題:值對象中的值竟然無法被EFCore儲存!也沒有抛出任何異常!我瞬間驚呆了!

  準确說,這裡說的應該碰到的兩個問題

  1、值對象中所有的數值資料都無法儲存更新

  2、值對象中的資料0無法儲存更新

  這兩個問題初看有點摸不着頭腦,後來不斷的嘗試,通過簡單的列印SQL,發現了一些端倪,但是儲存不了問什麼不抛出異常呢?這讓人有些費解,有點頭大,決定先做個筆記,以後找個時間再去看看源碼找找答案。

  首先,我建立了一個.net core控制台項目,嘗試的.net core版本是3.1.10,資料庫使用的是mysql(不知道是否與資料庫有關),然後使用NUGET安裝了如下包:  

Microsoft.EntityFrameworkCore
    Microsoft.EntityFrameworkCore.Tools
    Microsoft.Extensions.Logging.Console
    Pomelo.EntityFrameworkCore.MySql      

  然後建立如下檔案:  

  

EFCore:關于DDD中值對象(Owns)無法更新數值
EFCore:關于DDD中值對象(Owns)無法更新數值
using System;
    using System.Collections.Generic;
    using System.Text;
    
    namespace ConsoleApp8
    {
        public class MyTable
        {
            public MyTable()
            {
                MyOwns = new MyOwns();
            }
            public int Id { get; set; }
            public decimal DecimalValue1 { get; set; }
            public decimal DecimalValue2 { get; set; }
    
            public MyOwns MyOwns { get; set; }
        }
        public class MyOwns
        {
            public MyOwns() { }
            public MyOwns(decimal decimalValue1, decimal decimalValue2)
            {
                DecimalValue1 = decimalValue1;
                DecimalValue2 = decimalValue2;
            }
            public decimal DecimalValue1 { get; private set; }
            public decimal DecimalValue2 { get; private set; }
    
            public void Update(decimal decimalValue1, decimal decimalValue2)
            {
                DecimalValue1 = decimalValue1;
                DecimalValue2 = decimalValue2;
            }
        }
    
    }      

MyTable.cs

EFCore:關于DDD中值對象(Owns)無法更新數值
EFCore:關于DDD中值對象(Owns)無法更新數值
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;
using System;
    using System.Collections.Generic;
    using System.Text;
    
    namespace ConsoleApp8
    {
        public class DemoDbContext : DbContext
        {
            public DemoDbContext(DbContextOptions options) : base(options)
            {
    
            }
    
            public DbSet<MyTable> MyTable { get; set; }
    
            #region Method
            /// <summary>
            /// 配置
            /// </summary>
            /// <param name="optionsBuilder"></param>
            protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
            {
                var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
                optionsBuilder.UseLoggerFactory(loggerFactory);
                optionsBuilder.EnableSensitiveDataLogging();
                base.OnConfiguring(optionsBuilder);
            }
            /// <summary>
            /// 初始化
            /// </summary>
            /// <param name="modelBuilder"></param>
            protected override void OnModelCreating(ModelBuilder modelBuilder)
            {
                base.OnModelCreating(modelBuilder);
    
                var builder = modelBuilder.Entity<MyTable>();
                builder.HasKey(p => p.Id);
                builder.Property(p => p.DecimalValue1).HasColumnType($"decimal(18,6)");
                builder.Property(p => p.DecimalValue2).HasDefaultValue(0m).HasColumnType($"decimal(18,6)");
                builder.OwnsOne(f => f.MyOwns, o =>
                {
                    o.Property(p => p.DecimalValue1).HasColumnType($"decimal(18,6)");
                    o.Property(p => p.DecimalValue2).HasDefaultValue(0m).HasColumnType($"decimal(18,6)");
                });
            }
            #endregion
        }
    }      

DemoDbContext.cs

EFCore:關于DDD中值對象(Owns)無法更新數值
EFCore:關于DDD中值對象(Owns)無法更新數值
using Microsoft.EntityFrameworkCore;
    using Microsoft.EntityFrameworkCore.Design;
    using System;
    using System.Collections.Generic;
    using System.Text;
    
    namespace ConsoleApp8
    {
        public class DemoMigrationsDbContextFactory : IDesignTimeDbContextFactory<DemoDbContext>
        {
            public DemoDbContext CreateDbContext(string[] args)
            {
                var builder = new DbContextOptionsBuilder<DemoDbContext>()
                       .UseMySql("Server=192.168.209.128;Port=3306;Database=demodb;Uid=root;Pwd=123456");
    
                return new DemoDbContext(builder.Options);
            }
        }
    }      

DemoMigrationsDbContextFactory.cs

  然後使用【程式包管理器控制台】(導航欄【工具】=》【NuGet包管理器】=》【程式包管理器控制台】)輸入 Add-Migration init 生成遷移,輸入 Update-Database 更新遷移至資料庫,最後的結構類似這樣子:

EFCore:關于DDD中值對象(Owns)無法更新數值

   問題一:值對象中所有的數值資料都無法儲存更新

  這個問題最後發現挺巧合的,一方面又是因為EFCore生成的遷移中Owns類型盡然是nullable(可空)類型,一方面是自己對值對象的使用有問題。

  同樣的,在上面的MyTable類和MyOwns類中,同樣的有DecimalValue1和DecimalValue2兩個數值,但是生成的遷移檔案中兩者就差別了:  

migrationBuilder.CreateTable(
        name: "MyTable",
        columns: table => new
        {
            Id = table.Column<int>(nullable: false)
                .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
            DecimalValue1 = table.Column<decimal>(type: "decimal(18,6)", nullable: false),
            DecimalValue2 = table.Column<decimal>(type: "decimal(18,6)", nullable: false, defaultValue: 0m),
            MyOwns_DecimalValue1 = table.Column<decimal>(type: "decimal(18,6)", nullable: true),
            MyOwns_DecimalValue2 = table.Column<decimal>(type: "decimal(18,6)", nullable: true, defaultValue: 0m)
        },
        constraints: table =>
        {
            table.PrimaryKey("PK_MyTable", x => x.Id);
        });      

  可以看到MyTable類中的屬性被映射成 nullable:false ,而且使用 IsRequired(false) 設定時,生成遷移過程中将會抛出異常,但是MyOwns類中的屬性竟然直接被映射成了 nullable:true !!!

  這樣就問題來了,如果因為某些原因,導緻資料庫中這些字段未null,但是實體中的decimal等等屬性是非空的,那不就。。。這種情況是可能存在的,比如我上線時是先更新腳本,在更新系統前如果儲存資料,那這一列就有可能是null。

  如果僅僅因為這點,還不至于問題出現,但是如果在錯誤使用值對象(Owns)時就可能出現這種問題,直接上測試代碼:

class Program
    {
        static void Main(string[] args)
        {
            //清空表資料
            using (var db = new DemoMigrationsDbContextFactory().CreateDbContext(args))
            {
                using (var connection = db.Database.GetDbConnection())
                {
                    connection.Open();
                    var cmd = connection.CreateCommand();
                    cmd.CommandText = $@"delete from {nameof(MyTable)}";
                    cmd.ExecuteNonQuery();
                }
            }

            //新增一條資料
            using (var db = new DemoMigrationsDbContextFactory().CreateDbContext(args))
            {
                var myTable = new MyTable()
                {
                    Id = 1,
                    DecimalValue1 = 1m,
                    DecimalValue2 = 2m,
                    MyOwns = new MyOwns(1m, 2m)
                };
                db.MyTable.Add(myTable);
                db.SaveChanges();
            }

            //修改數值為空資料
            using (var db = new DemoMigrationsDbContextFactory().CreateDbContext(args))
            {
                using (var connection = db.Database.GetDbConnection())
                {
                    connection.Open();
                    var cmd = connection.CreateCommand();
                    cmd.CommandText = $@"update {nameof(MyTable)} set {nameof(MyTable.MyOwns)}_{nameof(MyOwns.DecimalValue1)}=null where Id=1";
                    cmd.ExecuteNonQuery();
                }
            }

            //修改資料
            using (var db = new DemoMigrationsDbContextFactory().CreateDbContext(args))
            {
                var myTable = db.MyTable.Find(1);
                myTable.DecimalValue1 = 10m;
                myTable.DecimalValue2 = 20m;
                //myTable.MyOwns = new MyOwns(10m, 20m); //正确用法
                myTable.MyOwns.Update(10m, 20m);    //錯誤用法,值對象應該指派,不應該修改其裡面的值!
                db.SaveChanges();
            }

            Console.WriteLine("Ok.");
            Console.ReadLine();
        }
    }      

  上面測試會列印出SQL,其中修改資料使用Find方法的查詢SQL如下:  

SELECT `m`.`Id`, `m`.`DecimalValue1`, `m`.`DecimalValue2`, `t`.`Id`, `t`.`MyOwns_DecimalValue1`, `t`.`MyOwns_DecimalValue2`
      FROM `MyTable` AS `m`
      LEFT JOIN (
          SELECT `m0`.`Id`, `m0`.`MyOwns_DecimalValue1`, `m0`.`MyOwns_DecimalValue2`
          FROM `MyTable` AS `m0`
          WHERE `m0`.`MyOwns_DecimalValue2` IS NOT NULL AND `m0`.`MyOwns_DecimalValue1` IS NOT NULL
      ) AS `t` ON `m`.`Id` = `t`.`Id`
      WHERE `m`.`Id` = @__p_0
      LIMIT 1      

  可以看到,值對象中的資料是通過Left Join得到的,而且Left Join中的條件都是 IS NOT NULL ,這樣值對象就相當于查出來一個null空對象,這樣,值對象中的屬性自然就不會被EFCore跟蹤記錄了。

  而如果此時,我們直接給值對象的屬性指派,那自然就不會被更新了,比如上面demo中,我使用的是值對象裡面自定義的Update方法來更新資料,這種做法是錯誤的,确實,更新列印出來的SQL如下:  

UPDATE `MyTable` SET `DecimalValue1` = @p0, `DecimalValue2` = @p1
      WHERE `Id` = @p2;
      SELECT ROW_COUNT();      

  值對象應該指派,不應該修改其裡面的值,那怕隻是修改一個屬性也應該使用一個新的值對象來指派,換句話說,我們應該把值對象當做int,string,DateTime等類型一樣來看待!!!

  問題二:值對象中的資料0無法儲存更新

  解決上面的問題一後,又遇到另一個問題,發現0無法被更新,而其它資料(如,1,2,3等)都可以被更新,測試代碼如下:  

class Program
    {
      static void Main(string[] args)
        {
            //清空表資料
            using (var db = new DemoMigrationsDbContextFactory().CreateDbContext(args))
            {
                using (var connection = db.Database.GetDbConnection())
                {
                    connection.Open();
                    var cmd = connection.CreateCommand();
                    cmd.CommandText = $@"delete from {nameof(MyTable)}";
                    cmd.ExecuteNonQuery();
                }
            }

            //新增一條資料
            using (var db = new DemoMigrationsDbContextFactory().CreateDbContext(args))
            {
                var myTable = new MyTable()
                {
                    Id = 1,
                    DecimalValue1 = 1m,
                    DecimalValue2 = 2m,
                    MyOwns = new MyOwns(1m, 2m)
                };
                db.MyTable.Add(myTable);
                db.SaveChanges();
            }

            //修改資料
            using (var db = new DemoMigrationsDbContextFactory().CreateDbContext(args))
            {
                var myTable = db.MyTable.Find(1);
                myTable.DecimalValue1 = 0m;
                myTable.DecimalValue2 = 0m;
                myTable.MyOwns = new MyOwns(0m, 0m);
                db.SaveChanges();
            }

            Console.WriteLine("Ok.");
            Console.ReadLine();
        }
    }      

  運作之後,修改資料部分的Find方法列印出的SQL如下:  

SELECT `m`.`Id`, `m`.`DecimalValue1`, `m`.`DecimalValue2`, `t`.`Id`, `t`.`MyOwns_DecimalValue1`, `t`.`MyOwns_DecimalValue2`
      FROM `MyTable` AS `m`
      LEFT JOIN (
          SELECT `m0`.`Id`, `m0`.`MyOwns_DecimalValue1`, `m0`.`MyOwns_DecimalValue2`
          FROM `MyTable` AS `m0`
          WHERE `m0`.`MyOwns_DecimalValue2` IS NOT NULL AND `m0`.`MyOwns_DecimalValue1` IS NOT NULL
      ) AS `t` ON `m`.`Id` = `t`.`Id`
      WHERE `m`.`Id` = @__p_0
      LIMIT 1      

  這一點和上面的例子是一樣的,但是更新的SQL卻是:  

UPDATE `MyTable` SET `DecimalValue1` = @p0, `DecimalValue2` = @p1, `MyOwns_DecimalValue1` = @p2
      WHERE `Id` = @p3;
      SELECT `MyOwns_DecimalValue2`
      FROM `MyTable`
      WHERE ROW_COUNT() = 1 AND `Id` = @p3;      

  可以看到,MyOwns_DecimalValue1和DecimalValue1、DecimalValue2都更新了,但是MyOwns_DecimalValue2沒有被更新!!!

  這裡,我們在用法上基本上沒什麼問題,于是我猜想是EFCore遷移映射導緻的,檢視DbContext的 OnModelCreating 方法:  

/// <summary>
    /// 初始化
    /// </summary>
    /// <param name="modelBuilder"></param>
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        var builder = modelBuilder.Entity<MyTable>();
        builder.HasKey(p => p.Id);
        builder.Property(p => p.DecimalValue1).HasColumnType($"decimal(18,6)");
        builder.Property(p => p.DecimalValue2).HasDefaultValue(0m).HasColumnType($"decimal(18,6)");
        builder.OwnsOne(f => f.MyOwns, o =>
        {
            o.Property(p => p.DecimalValue1).HasColumnType($"decimal(18,6)");
            o.Property(p => p.DecimalValue2).HasDefaultValue(0m).HasColumnType($"decimal(18,6)");
        });
    }      

  這裡,MyOwns的DecimalValue1和DecimalValue2僅僅差别一個預設值!去掉DecimalValue2的預設值,運作測試,成功更新!!!但是奇怪的是,MyTable的DecimalValue1和DecimalValue2卻不受預設值的影響。

  總結

  DDD(領域驅動設計)是應對複雜軟體設計的利器,而EFCore為DDD中的實體,值類型等持久化提供了非常友善的解決方案,但是在使用時,我們要切記:

  1、值對象要當做和int,String,DateTime等類型一樣使用,哪怕是修改值對象中一個屬性,也需要從新建立一個值對象!

  2、EFCore提供的OwnsOne或者OwnsMany方法關聯的值對象中的屬性預設是可空的,而對實體則是會根據屬性類型是否可空而定,是以使用時要根據自己的需求而定。

  3、EFCore提供的OwnsOne或者OwnsMany方法關聯的值對象中的屬性盡可能不要設定預設值,這裡筆者隻是用decimal類型碰到了,但是不排除還有其它類型也會有這樣的問題

  4、目前這幾點在.net 5.0簡單測試過了,結果也是一樣,那麼估計是有意這麼做的,是以大家使用時多留意吧

一個專注于.NetCore的技術小白

繼續閱讀