实体框架核心现代数据访问教程(三)

原文:Modern Data Access with Entity Framework Core

协议:CC BY-NC-SA 4.0

十、插入、删除和修改记录

在许多地方,与传统的实体框架相比,在实体框架核心中插入、删除和修改记录的 API 和方法保持不变。但是在细节上有一些变化,特别是当将多个变化合并到数据库管理系统的一个批处理往返中时。

您可以随时写入从数据库加载的实体对象。您不必在写操作之前“宣布”它们,也不必在写操作之后“注册它们”。实体框架核心的上下文类(更准确地说,是内置的变更跟踪器)跟踪标准系统中对象的所有变更(称为变更跟踪)。但是,如果对象是在非跟踪模式(例如使用AsNoTracking())下加载的,则不会发生更改跟踪,这是专门设置的,或者上下文实例被破坏。

使用 SaveChanges()保存

清单 10-1 展示了如何用SingleOrDefault()加载一个Flight对象。在这个Flight对象中,自由席位的数量减少了两个位置。此外,一些文本被写到Flight对象的memo属性中。

SaveChanges()方法用于存储数据库中的更改。它在基类DbContext中实现,并从那里继承到您在逆向工程中生成的上下文类,或者它在正向工程中创建自己。

SaveChanges()方法保存自加载以来的所有更改(新记录、已更改的记录和已删除的记录),或者保存当前上下文实例中加载的所有对象上的最后一个SaveChanges()方法。SaveChanges()向数据库发送一个INSERTUPDATEDELETE命令来保存每个更改。

Note

即使在实体框架核心中,不幸的是,当存在多个变更时,也不可能只保存单个变更。

当然,SaveChanges()只保存被改变的对象和被改变对象的被改变的属性。图 10-1 中显示的 SQL 输出证明了这一点。在UPDATE命令的SET部分,只有FreeSeatsMemo出现。您还可以看到,UPDATE命令返回已更改记录的数量。调用者从SaveChanges()接收这个数字作为返回值。

UPDATE命令只包含WHERE条件下的FlightNo值。换句话说,这里没有检查对象是否被另一个用户或后台进程更改。实体框架核心的标准是“最后一个胜出”的原则但是,您可以更改这种默认行为(参见第十七章)。只有当数据库中要更改的数据记录被删除时,调用者才会得到类型为DbConcurrencyException的运行时错误。然后,UPDATE命令从数据库管理系统返回零个记录受变更影响,实体框架核心将此视为变更冲突的指示。

清单 10-1 打印了三次关于Flight对象的信息(更改前、更改后和保存后)。除了FlightNo(主键)和Flight路线(出发地和目的地),还打印FreeSeats的编号和对象的当前状态。状态不能由实体对象本身决定,只能由带有ctx.Entry(obj).State的上下文类的Entry()方法决定。

  public static void ChangeFlightOneProperty()
  {
   CUI.MainHeadline(nameof(ChangeFlightOneProperty));

   int FlightNr = 101;
   using (WWWingsContext ctx = new WWWingsContext())
   {
    // Load flight
    var f = ctx.FlightSet.Find(FlightNr);

    Console.WriteLine($"Before changes: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);

    // Change object in RAM
    f.FreeSeats -= 2;

    Console.WriteLine($"After changes: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);

    // Persist changes
    try
    {
     var count = ctx.SaveChanges();
     if (count == 0)
     {
      Console.WriteLine("Problem: No changes saved!");
     }
     else
     {
      Console.WriteLine("Number of saved changes: " + count);
      Console.WriteLine($"After saving: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);
     }
    }
    catch (Exception ex)
    {
     Console.WriteLine("Error: " + ex.ToString());
    }
   }
  }

Listing 10-1One changed Property of Flights is Saved

图 10-1 从实体框架核心的角度展示了对象的状态是如何变化的。装完后就是Unchanged。将 change 设置为Modified后,实体框架核心就知道对象发生了变化。用SaveChanges()救了之后,又是Unchanged。换句话说,RAM 中的状态再次对应于数据库中的状态。

当执行SaveChanges()方法时,很可能出现错误(例如,dbConcurrencyException)。因此,在清单 10-1 中,有明确的try-catchSaveChanges()SaveChanges()上另一个典型的运行时错误是当。NET Framework 允许从数据库的角度写入未经授权的值。例如,如果这个列在数据库中有长度限制,那么使用Memo属性就会发生这种情况。既然琴弦在。NET 的长度基本上是无限的,从数据库的角度来看,可能会给属性分配一个太长的字符串。

Note

与经典的实体框架不同,实体框架核心在用SaveChanges()保存之前不做任何验证。换句话说,无效值首先被数据库管理系统注意到。在这种情况下,您会得到以下运行时错误:“Microsoft。EntityFrameworkCore . DbUpdateException:更新条目时出错。有关详细信息,请参见内部异常。然后,内部异常对象提供了错误的实际来源:“System。DataSqlClient.SqlException:字符串或二进制数据将被截断。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-1

Output of Listing 10-1

下面是清单 10-1 中实体框架核心在SaveChanges()发出的 SQL 命令:

exec sp_executesql N'SET NOCOUNT ON;
UPDATE [Flight] SET [FreeSeats] = @p0
WHERE [FlightNo] = @p1;
SELECT @@ROWCOUNT;
',N'@p1 int,@p0 smallint',@p1=101,@p0=114'

跟踪子对象的变更

变更跟踪在 Entity Framework Core(与其前身一样)中对变更的子对象起作用。清单 10-2 加载Flight对象及其Pilot对象。对Flight对象以及连接的Pilot对象进行更改(增加PilotFlight时间)。Pilot对象的状态类似于Flight对象,从Unchanged变为Modified,并在SaveChanges()执行后再次变为Unchanged

  public static void ChangeFlightAndPilot()
  {
   CUI.MainHeadline(nameof(ChangeFlightAndPilot));

   int flightNo = 101;
   using (WWWingsContext ctx = new WWWingsContext())
   {

    var f = ctx.FlightSet.Include(x => x.Pilot).SingleOrDefault(x => x.FlightNo == flightNo);

    Console.WriteLine($"After loading: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats!\nState of the flight object: " + ctx.Entry(f).State + " / State of the Pilot object: " + ctx.Entry(f.Pilot).State);
    f.FreeSeats -= 2;
    f.Pilot.FlightHours = (f.Pilot.FlightHours ?? 0) + 10;
    f.Memo = $"Changed by User {System.Environment.UserName} on {DateTime.Now}.";

    Console.WriteLine($"After changes: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats!\nState of the flight object: " + ctx.Entry(f).State + " / State of the Pilot object: " + ctx.Entry(f.Pilot).State);

    try
    {
     var count = ctx.SaveChanges();
     if (count == 0) Console.WriteLine("Problem: No changes saved!");
     else Console.WriteLine("Number of saved changes: " + count);
     Console.WriteLine($"After saving: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats!\nState of the flight object: " + ctx.Entry(f).State + " / State of the Pilot object: " + ctx.Entry(f.Pilot).State);
    }
    catch (Exception ex)
    {
     Console.WriteLine("Error: " + ex.ToString());
    }
   }
  }

Listing 10-2Changes in Subobjects

以下是实体框架核心发送给SaveChanges()的 SQL 命令;它显示两个UPDATE命令被发送到数据库管理系统。图 10-2 显示了输出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-2

Output of the previous code

exec sp_executesql N'SET NOCOUNT ON;
UPDATE [Employee] SET [FlightHours] = @p0
WHERE [PersonID] = @p1;
SELECT @@ROWCOUNT;

UPDATE [Flight] SET [FreeSeats] = @p2, [Memo] = @p3
WHERE [FlightNo] = @p4;
SELECT @@ ROWCOUNT;

',N'@p1 int,@p0 int,@p4 int,@p2 smallint,@p3 nvarchar(4000)',@p1=57,@p0=40,@p4=101,@p2=104,@p3=N'Changed by User HS on 23/12/2017 00:53:12.'

组合命令(批处理)

与经典的实体框架相反,实体框架核心并不在其自身的往返行程中将每个INSERTUPDATEDELETE命令发送到数据库管理系统;相反,它将命令组合成更大的往返行程。这种功能称为批处理。

实体框架核心决定往返的命令摘要的大小。在对Flight数据集的大规模插入的测试中,300 个数据集用于两次往返;1000 次用于六次往返旅行;2000 被用于 11;5000 次用于数据库管理系统的 27 次往返。

除了Add()方法之外,在上下文类和DbSet<EntityClass>类上都有一个AddRange()方法,您可以向其传递要附加的对象列表。在经典的实体框架中,AddRange()Add()要快得多,因为它消除了重复审查实体框架变更跟踪程序的需要。实体框架核心不再有在一个循环中调用Add()1000 次或者用一组 1000 个对象作为参数调用AddRange()一次的性能差异。图 10-3 中一个清晰可见的性能优势是由批处理产生的。但是如果你总是在Add()之后直接调用SaveChanges(),就不可能进行批处理(见图 10-3 中的第三条)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-3

Power measurement during mass insertion of 1,000 records

处理 foreach 循环注意事项

使用 Entity Framework,在使用转换操作符(如ToList())进行迭代之前,没有必要显式具体化查询。一个带有IQueryable接口的对象上的foreach循环足以触发数据库查询。然而,在这种情况下,当循环运行时,数据库连接保持打开,记录由IQueryable接口的迭代器单独获取。这导致在数据获取foreach循环中调用SaveChanges()导致运行时错误,如清单 10-3 和图 10-4 所示。

这里列出了三种解决方案:

  • 最好的解决方案是在开始循环之前用ToList()完全具体化查询,并将SaveChanges()放在循环之后。这导致在一次或几次往返中传输所有变化。但是所有的变化都有一个交易!
  • 如果请求多个事务中的改变,那么在循环之前至少应该执行ToList()
  • 或者,可以用SaveChangesAsync()代替SaveChanges();更多信息见第十三章。

Tip

使用带有ToList()的显式物化。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-4

Error from running Listing 10-3

  public static void Demo_ForeachProblem()
  {
   CUI.Headline(nameof(Demo_ForeachProblem));
   WWWingsContext ctx = new WWWingsContext();
   // Define query
   var query = (from f in ctx.FlightSet.Include(p => p.BookingSet).ThenInclude(b => b.Passenger) where f.Departure == "Rome" && f.FreeSeats > 0 select f).Take(1);
   // Query is performed implicitly by foreach
   foreach (var Flight in query)
   {
    // Print results
    CUI.Print("Flight: " + Flight.FlightNo + " from " + Flight.Departure + " to " + Flight.Destination + " has " + Flight.FreeSeats + " free seats");

    foreach (var p in Flight.BookingSet)
    {
     CUI.Print(" Passenger  " + p.Passenger.GivenName + " " + p.Passenger.Surname);
    }

    // Save change to every flight object within the loop
    CUI.Print("  Start saving");
    Flight.FreeSeats--;
    ctx.SaveChangesAsync(); // SaveChanges() will produce ERROR!!!
    CUI.Print("  End saving");
   }
  }

Listing 10-3SaveChanges() Does Not Work Within a foreach Loop Unless You Have Previously Materialized the Records

添加新对象

要添加一条新记录(在 SQL 中,使用INSERT),您可以使用实体框架核心执行以下步骤:

  1. new操作符实例化对象(像往常一样在。网)。与经典实体框架中一样,工厂方法在实体框架核心中并不存在。
  2. 从数据库模式的角度来看,填充对象,尤其是所有强制属性。
  3. 通过 context 类中的Add()方法或 context 类中适当的DbSet<EntityClass>将对象附加到上下文中。
  4. 调用SaveChanges()

清单 10-4 展示了如何创建一个Flight对象。强制要求是航班号(这里的主键不是自动增加的值,因此必须手动设置)、航线、出发、目的地和日期,以及与Pilot对象的关系。副驾驶是可选的。即使航空公司是强制字段,程序代码也可以在没有显式分配枚举值的情况下工作,因为实体框架核心将在这里使用默认值 0,这是数据库的有效值。

作为预先加载Pilot对象然后将其分配给Flight对象的替代方法,您可以通过使用Flight对象中的外键属性PilotId并在那里直接分配Pilot对象的主键:f.PilotId = 234来更有效地实现该任务。在这里,您将看到显式外键属性的优点,它可以节省数据库的往返行程。

  public static void AddFlight()
  {
   CUI.MainHeadline(nameof(AddFlight));

   using (WWWingsContext ctx = new WWWingsContext())
   {
    // Create flight in RAM
    var f = new Flight();
    f.FlightNo = 123456;
    f.Departure = "Essen";
    f.Destination = "Sydney";
    f.AirlineCode = "WWW";
    f.PilotId = ctx.PilotSet.FirstOrDefault().PersonID;
    f.Seats = 100;
    f.FreeSeats = 100;

    Console.WriteLine($"Before adding: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the Flight object: " + ctx.Entry(f).State);

    // Add flight to context
    ctx.FlightSet.Add(f);
    // or: ctx.Add(f);

    Console.WriteLine($"After adding: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the Flight object: " + ctx.Entry(f).State);

    try
    {
     var count = ctx.SaveChanges();
     if (count == 0) Console.WriteLine("Problem: No changes saved!");
     else Console.WriteLine("Number of saved changes: " + count);
     Console.WriteLine($"After saving: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the Flight object: " + ctx.Entry(f).State);
    }
    catch (Exception ex)
    {
     Console.WriteLine("Error: " + ex.ToString());
    }

   }
  }

Listing 10-4Creating a New Flight

对象状态的顺序如下(见图 10-5 ): Detached(在执行Add()之前,实体框架核心上下文不知道Flight的新实例,因此认为它是一个瞬态对象),Added(在add()之后),然后在保存Unchanged之后。顺便说一句,实体框架核心并不认为Add()的多次调用是错误的,但是Add()不需要被多次调用。

Note

您可以添加一个主键值尚不存在于当前上下文实例中的对象。如果你想删除一个对象,然后在相同的主键值下创建一个新的对象,你必须在Remove()之后、Add()之前执行SaveChanges();否则,实体框架核心会报错,并显示以下错误消息:“System。InvalidOperationException:无法跟踪实体类型“Flight”的实例,因为已经在跟踪{“Flight no”}的另一个具有相同键值的实例。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-5

Output from Listing 10-4 (creating a Flight object)

创建相关对象

Add()方法不仅考虑作为参数传递的对象,还考虑与该对象相关联的对象。如果在状态Detached中作为参数传递的对象下有对象,它们被自动添加到上下文中,然后处于状态Added

1:N 情况下的关系操作是在第 1 方通过用特定于列表的方法添加和删除列表来完成的,主要是用方法Add()Remove()。对于双向关系,可以在第 1 端或第 N 端进行更改。在FlightPilot之间的双向关系的具体情况下,有三种等价的建立关系的方式,这里列出:

  • 利用 1 侧的导航属性,所以Flight : flight.Pilot = Pilot;中的Pilot
  • 使用Flight : flight.PersonID = 123;中的外键属性PersonID
  • 使用 N 端的导航属性,换句话说,Pilot页面上的:】页

清单 10-5 展示了如何用一个新的Pilot对象、一个新的AircraftType对象和一个新的AircraftTypeDetail对象创建一个新的Flight。对Flight对象执行Add()就足够了。然后,实体框架核心向数据库发送SaveChanges()五个INSERT命令,每个命令对应一个表:AircraftTypeAircraftTypeDetailEmployees(EmployeePilot类实例的公共表)、PersondetailFlight

  public static void Demo_CreateRelatedObjects()
  {
   CUI.MainHeadline(nameof(Demo_CreateRelatedObjects));
   using (var ctx = new WWWingsContext())
   {
    ctx.Database.ExecuteSqlCommand("Delete from Booking where FlightNo = 456789");
    ctx.Database.ExecuteSqlCommand("Delete from Flight where FlightNo = 456789");

    var p = new Pilot();
    p.GivenName = "Holger";
    p.Surname = "Schwichtenberg";
    p.HireDate = DateTime.Now;
    p.LicenseDate = DateTime.Now;
    var pd = new Persondetail();
    pd.City = "Essen";
    pd.Country = "DE";
    p.Detail = pd;

    var act = new AircraftType();
    act.TypeID = (byte)(ctx.AircraftTypeSet.Max(x=>x.TypeID)+1);
    act.Manufacturer = "Airbus";
    act.Name = "A380-800";
    ctx.AircraftTypeSet.Add(act);
    ctx.SaveChanges();

    var actd = new AircraftTypeDetail();
    actd.TurbineCount = 4;
    actd.Length = 72.30f;
    actd.Tare = 275;
    act.Detail = actd;

    var f = new Flight();
    f.FlightNo = 456789;
    f.Pilot = p;
    f.Copilot = null;
    f.Seats = 850;
    f.FreeSeats = 850;
    f.AircraftType = act;

    // One Add() is enough for all related objects!
    ctx.FlightSet.Add(f);
    ctx.SaveChanges();

    CUI.Print("Total number of flights: " + ctx.FlightSet.Count());
    CUI.Print("Total number of pilots: " + ctx.PilotSet.Count());
   }
  }

Listing 10-5Creation of a New Pilot’s Flight with Persondetail, New AircraftType, and AircraftTypeDetail

图 10-6 显示了这五个INSERT命令的输出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-6

Batch updating makes only three round-trips for five INSERT commands

更改链接对象

实体框架核心还检测实体对象之间的关系变化,并自动保存在SaveChanges()中。就像最初用Pilot创建一个Flight对象一样,在理想情况下有三个改变关系的选项(带有外键属性的双向关系):

  • 使用Flight : Flight.Pilot = Pilot;中的导航属性Pilot
  • 使用Flight : Flight.PersonID = 123;中的外键属性PilotId
  • 使用Pilot页面上的导航属性FlightAsPilotSet:Pilot.FlightAsPilotSet.Add (Flight);

在所有这三种情况下,实体框架核心都向数据库发送SaveChanges()。通过执行SaveChanges(),实体框架核心正确地不对Pilot表中的数据库进行任何更改,而是对Flight表中的数据库进行任何更改,因为Flight表具有建立PilotFlight之间关系的外键。

exec sp_executesql N'SET NOCOUNT ON;
UPDATE [Flight] SET [PilotId] = @p0
WHERE [FlightNo] = @p1;
SELECT @@ ROWCOUNT;
', N' @p1 int, @p0 int ', @p1 = 101, @p0 = 123

Tip

要删除关系,您可以简单地分配零或什么都不分配。

清单 10-6 显示了一个新Pilot到一个Flight的赋值。然而,这种分配不是通过Flight101.Pilot = newPilot(1 侧)进行的,而是通过newPilot.FlightAsPilotSet.Add(flight101)Pilot侧(N 侧)进行的。这个清单的输出是令人兴奋的;见图 10-7 。你可以看到,一开始,一个飞行员有 31 次飞行,另一个有 10 次飞行。分配后,新的Pilot有 11 个班次,旧的Pilot还是 31 个班次,不对。另外,flight101.Pilot还是指旧的Pilot,这也不对。

然而,在运行SaveChanges()之后,对象关系已经被纠正。现在旧的Pilot只有 30 个航班。另外,flight101.Pilot指的是新的Pilot。实体框架核心的这一特性被称为关系修复。作为关系修正操作的一部分,实体框架核心检查当前在 RAM 中的对象之间的所有关系,并且如果在另一侧发生了变化,也在另一侧改变它们。使用SaveChanges()保存时,实体框架核心运行关系修复操作。

通过执行方法ctx.ChangeTracker.DetectChanges(),您可以在任何时候强制执行关系修复操作。如果许多对象已经被加载到一个上下文实例中,DetectChanges()可能会花费许多毫秒。因此,在实体框架核心中,微软不会在很多地方自动调用DetectChanges(),而是由您来决定何时需要对象关系的一致状态并使用DetectChanges()。图 10-7 显示了输出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-7

Output of Listing 10-6 (the relationship fixup works)

  public static void Demo_RelationhipFixup1N()
  {
   CUI.MainHeadline(nameof(Demo_RelationhipFixup1N));
   using (var ctx = new WWWingsContext())
   {
    // Load a flight
    var flight101 = ctx.FlightSet.SingleOrDefault(x => x.FlightNo == 101);
    Console.WriteLine($"Flight Nr {flight101.FlightNo} from {flight101.Departure} to {flight101.Destination} has {flight101.FreeSeats} free seats!");

    // Load the pilot for this flight with the list of his flights
    var oldPilot = ctx.PilotSet.Include(x => x.FlightAsPilotSet).SingleOrDefault(x => x.PersonID == flight101.PilotId);
    Console.WriteLine("Pilot: " + oldPilot.PersonID + ": " + oldPilot.GivenName + " " + oldPilot.Surname + " has " + oldPilot.FlightAsPilotSet.Count + " flights as pilot!");

    // Next pilot in the list load with the list of his flights
    var newPilot = ctx.PilotSet.Include(x => x.FlightAsPilotSet).SingleOrDefault(x => x.PersonID == flight101.PilotId + 1);
    Console.WriteLine("Planned Pilot: " + newPilot.PersonID + ": " + newPilot.GivenName + " " + newPilot.Surname + " has " + newPilot.FlightAsPilotSet.Count + " flights as pilot!");

    // Assign to Flight
    CUI.Print("Assignment of the flight to the planned pilot...", ConsoleColor.Cyan);
    newPilot.FlightAsPilotSet.Add(flight101);

    // optional:force Relationship Fixup
    // ctx.ChangeTracker.DetectChanges();

    CUI.Print("Output before saving: ", ConsoleColor.Cyan);
    Console.WriteLine("Old pilot: " + oldPilot.PersonID + ": " + oldPilot.GivenName + " " + oldPilot.Surname + " has " + oldPilot.FlightAsPilotSet.Count + " flights as a pilot!");
    Console.WriteLine("New pilot: " + newPilot.PersonID + ": " + newPilot.GivenName + " " + newPilot.Surname + " has " + newPilot.FlightAsPilotSet.Count + " flights as a pilot!");
    var pilotAktuell = flight101.Pilot; // Current Pilot in the Flight object
    Console.WriteLine("Pilot for flight " + flight101.FlightNo + " is currently: " + pilotAktuell.PersonID + ": " + pilotAktuell.GivenName + " " + pilotAktuell.Surname);

    // SaveChanges()()
    CUI.Print("Saving... ", ConsoleColor.Cyan);
    var count = ctx.SaveChanges();
    CUI.MainHeadline("Number of saved changes: " + count);

    CUI.Print("Output after saving: ", ConsoleColor.Cyan);
    Console.WriteLine("Old Pilot: " + oldPilot.PersonID + ": " + oldPilot.GivenName + " " + pilotAlt.Surname + " has " + pilotAlt.FlightAsPilotSet.Count + " flights as pilot!");
    Console.WriteLine("New Pilot: " + newPilot.PersonID + ": " + newPilot.GivenName + " " + newPilot.Surname + " has " + newPilot.FlightAsPilotSet.Count + " flights as pilot!");
    pilotAktuell = flight101.Pilot; // Current pilot from the perspective of the Flight object
    Console.WriteLine("Pilot for Flight " + flight101.FlightNo + " is now: " + pilotAktuell.PersonID + ": " + pilotAktuell.GivenName + " " + pilotAktuell.Surname);
   }
  }

Listing 10-6Making a 1:N Relationship Across the First Page

处理矛盾的关系

如果像前面解释的那样,有多达三种方法来建立对象之间的关系,那么当多个选项与矛盾的数据并行使用时会发生什么?

列表 10-7 ,结合图 10-8 和图 10-9 的输出,显示优先级如下:

  • 最高优先级是设置在 1 侧的对象的值,所以在关系Pilot<->Flight (1:N)的情况下,来自Pilot.FlightAsPilotSet的值是第一个。
  • 第二高的优先级是来自 N 侧的单个对象的值。换句话说,在Pilot<->Flight (1:N)的情况下,是来自Flight.Pilot的值。
  • 只有这样,N 端才会考虑外键属性。换句话说,在Pilot: Flight (1:N)的情况下,是来自Flight.PersonID的值。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-9

Output of Listing 10-7 (part 2)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-8

Output of Listing 10-7 (part 1)

using System;
using System.Linq;
using DA;
using EFC_Console;
using ITVisions;
using Microsoft.EntityFrameworkCore;
namespace EFC_Console
{
 class ContradictoryRelationships
 {
  /// <summary>
  /// Four test scenarios for the question of which value has priority, if the relationship is set contradictory
  /// </summary>
  [EFCBook()]
  public static void Demo_ContradictoryRelationships()
  {
   CUI.MainHeadline(nameof(Demo_ContradictoryRelationships));
   Attempt1();
   Attempt2();
   Attempt3();
   Attempt4();
  }

  public static int pilotID = new WWWingsContext().PilotSet.Min(x => x.PersonID);

  public static int GetPilotIdEinesFreienPilots()
  {
   // here we assume that the next one in the list has time for this flight :-)
   pilotID++; return pilotID;
  }

  private static void Attempt1()
  {
   using (var ctx = new WWWingsContext())
   {
    CUI.MainHeadline("Attempt 1: first assignment by navigation property, then by foreign key property");
    CUI.PrintStep("Load a flight...");
    var flight101 = ctx.FlightSet.Include(f => f.Pilot).SingleOrDefault(x => x.FlightNo == 101);
    Console.WriteLine($"Flight Nr {flight101.FlightNo} from {flight101.Departure} to {flight101.Destination} has {flight101.FreeSeats} free seats!");
    CUI.Print("Pilot object: " + flight101.Pilot.PersonID + " PilotId: " + flight101.PilotId);

    CUI.PrintStep("Load another pilot...");
    var newPilot2 = ctx.PilotSet.Find(GetPilotIdEinesFreienPilots()); // next Pilot
    CUI.PrintStep($"Assign a new pilot #{newPilot2.PersonID} via navigation property...");
    flight101.Pilot = newPilot2;
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");

    CUI.PrintStep("Reassign a new pilot via foreign key property...");
    var neuePilotID = GetPilotIdEinesFreienPilots();
    CUI.PrintStep($"Assign a new pilot #{neuePilotID} via foreign key property...");
    flight101.PilotId = neuePilotID;
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");

    CUI.PrintStep("SaveChanges()");
    var anz2 = ctx.SaveChanges();
    CUI.PrintSuccess("Number of saved changes: " + anz2);

    CUI.PrintStep("Control output after saving: ");
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");
   }
  }

  private static void Attempt2()
  {
   using (var ctx = new WWWingsContext())
   {
    CUI.MainHeadline("Attempt 2: First assignment by foreign key property, then navigation property");
    CUI.PrintStep("Load a flight...");
    var flight101 = ctx.FlightSet.Include(f => f.Pilot).SingleOrDefault(x => x.FlightNo == 101);
    Console.WriteLine($"Flight Nr {flight101.FlightNo} from {flight101.Departure} to {flight101.Destination} has {flight101.FreeSeats} free seats!");
    CUI.Print("Pilot object: " + flight101.Pilot.PersonID + " PilotId: " + flight101.PilotId);

    var neuePilotID2 = GetPilotIdEinesFreienPilots();
    CUI.PrintStep($"Assign a new pilot #{neuePilotID2} via foreign key property...");
    flight101.PilotId = neuePilotID2;
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");

    CUI.PrintStep("Load another pilot...");
    var newPilot1 = ctx.PilotSet.Find(GetPilotIdEinesFreienPilots()); // next Pilot
    CUI.PrintStep($"Assign a new pilot #{newPilot1.PersonID} via navigation property...");
    flight101.Pilot = newPilot1;
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");

    CUI.PrintStep("SaveChanges()");
    var anz2 = ctx.SaveChanges();
    CUI.PrintSuccess("Number of saved changes: " + anz2);

    CUI.PrintStep("Control output after saving: ");
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");
   }
  }

  private static void Attempt3()
  {
   using (var ctx = new WWWingsContext())
   {
    CUI.MainHeadline("Attempt 3: Assignment using FK, then Navigation Property at Flight, then Navigation Property at Pilot");
    CUI.PrintStep("Load a flight...");
    var flight101 = ctx.FlightSet.Include(f => f.Pilot).SingleOrDefault(x => x.FlightNo == 101);
    Console.WriteLine($"Flight No {flight101.FlightNo} from {flight101.Departure} to {flight101.Destination} has {flight101.FreeSeats} free seats!");
    CUI.Print("Pilot object: " + flight101.Pilot.PersonID + " PilotId: " + flight101.PilotId);

    var neuePilotID3 = GetPilotIdEinesFreienPilots();
    CUI.PrintStep($"Assign a new pilot #{neuePilotID3} via foreign key property...");
    flight101.PilotId = neuePilotID3;
    CUI.Print("flight101.PilotId=" + flight101.PilotId);
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");

    CUI.PrintStep("Load another pilot...");
    var newPilot3a = ctx.PilotSet.Find(GetPilotIdEinesFreienPilots()); // next Pilot
    CUI.PrintStep($"Assign a new pilot #{newPilot3a.PersonID} via navigation property in Flight object...");
    flight101.Pilot = newPilot3a;
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");

    CUI.PrintStep("Load another Pilot...");
    var newPilot3b = ctx.PilotSet.Include(p => p.FlightAsPilotSet).SingleOrDefault(p => p.PersonID == GetPilotIdEinesFreienPilots()); // next Pilot
    CUI.PrintStep($"Assign a new pilot #{newPilot3b.PersonID} via navigation property in Pilot object...");
    newPilot3b.FlightAsPilotSet.Add(flight101);
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");

    CUI.PrintStep("SaveChanges()");
    var anz3 = ctx.SaveChanges();
    CUI.PrintSuccess("Number of saved changes: " + anz3);

    CUI.PrintStep("Control output after saving: ");
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");

   }
  }

  private static void Attempt4()
  {
   using (var ctx = new WWWingsContext())
   {
    CUI.MainHeadline("Attempt 4: First assignment by FK, then Navigation Property at Pilot, then Navigation Property at Flight");
    CUI.PrintStep("Load a flight...");
    var flight101 = ctx.FlightSet.Include(f => f.Pilot).SingleOrDefault(x => x.FlightNo == 101);
    Console.WriteLine($"Flight Nr {flight101.FlightNo} from {flight101.Departure} to {flight101.Destination} has {flight101.FreeSeats} free seats!");
    CUI.Print("Pilot object: " + flight101.Pilot.PersonID + " PilotId: " + flight101.PilotId);

    var neuePilotID4 = GetPilotIdEinesFreienPilots();
    CUI.PrintStep($"Assign a new pilot #{neuePilotID4} via foreign key property...");
    flight101.PilotId = neuePilotID4;
    CUI.Print("flight101.PilotId=" + flight101.PilotId);
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");

    CUI.PrintStep("Load another pilot...");
    var newPilot4b = Queryable.SingleOrDefault(ctx.PilotSet.Include(p => p.FlightAsPilotSet), p => p.PersonID == GetPilotIdEinesFreienPilots()); // next Pilot
    CUI.PrintStep($"Assign a new pilot #{newPilot4b.PersonID} via navigation property...");
    newPilot4b.FlightAsPilotSet.Add(flight101);
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");

    CUI.PrintStep("Load another Pilot...");
    var newPilot4a = ctx.PilotSet.Find(GetPilotIdEinesFreienPilots()); // next Pilot
    CUI.PrintStep($"Assign a new pilot #{newPilot4a.PersonID} via navigation property in Flight object...");
    flight101.Pilot = newPilot4a;
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");

    CUI.PrintStep("SaveChanges()");
    var anz4 = ctx.SaveChanges();
    CUI.PrintSuccess("Number of saved changes: " + anz4);

    CUI.PrintStep("Control output after saving: ");
    CUI.Print($"PilotId: {flight101.PilotId} Pilot object: {flight101.Pilot?.PersonID}");
   }
  }
 }
}

Listing 10-7Four Test Scenarios for the Question of Which Value Has Priority When the Relationship Is Contradictory

删除对象

本节介绍删除对象和数据库中相应行的不同方法。

使用 Remove()删除对象

要删除一个对象,你必须调用Remove()方法,它和Add()一样,要么直接存在于上下文类中(从DbContext继承而来),要么存在于上下文类的DbSet<EntityClass>属性中(参见清单 10-8 )。调用Remove()导致加载的Flight对象从Unchanged状态变为Delete状态(见图 10-10 )。但是,它尚未从数据库中删除。只有通过调用SaveChanges()方法,才会向数据库管理系统发送一个DELETE命令。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-10

Output of Listing 10-8

  public static void RemoveFlight()
  {
  CUI.MainHeadline(nameof(RemoveFlight));

   using (WWWingsContext ctx = new WWWingsContext())
   {
    var f = ctx.FlightSet.SingleOrDefault(x => x.FlightNo == 123456);
    if (f == null) return;

    Console.WriteLine($"After loading: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);

    // Remove flight
    ctx.FlightSet.Remove(f);
    // or: ctx.Remove(f);

    Console.WriteLine($"After deleting: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);

    try
    {
     var count = ctx.SaveChanges();
     if (count == 0) Console.WriteLine("Problem: No changes saved!");
     else Console.WriteLine("Number of saved changes: " + count);
     Console.WriteLine($"After saving: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);
    }
    catch (Exception ex)
    {
     Console.WriteLine("Error: " + ex.ToString());
    }
   }
  }

Listing 10-8Deleting a Flight Record

下面是清单 10-8 中实体框架核心发出的 SQL 命令:

SELECT TOP(2) [x].[FlightNo], [x].[AircraftTypeID], [x].[AirlineCode], [x].[CopilotId], [x].[FlightDate], [x].[Departure], [x].[Destination], [x].[FreeSeats], [x].[LastChange], [x].[Memo], [x].[NonSmokingFlight], [x].[PilotId], [x].[Price], [x].[Seats], [x].[Strikebound], [x].[Utilization]
FROM [Flight] AS [x]
WHERE [x].[FlightNo] = 123456

exec sp_executesql N'SET NOCOUNT ON;
DELETE FROM [Flight]
WHERE [FlightNo] = @p0;
SELECT @@ROWCOUNT;
',N'@p0 int',@p0=123456

删除带有虚拟对象的对象

在前面的代码中,完全加载Flight对象效率很低;您只发送删除命令。清单 10-9 显示了一个解决方案,它通过在 RAM 中创建一个Flight对象来避免这种到数据库管理系统的往返,其中只有主键被设置为要删除的对象。然后用Attach()将这个虚拟对象附加到上下文中。这使得对象的状态从Detached变为Unchanged。最后,你执行Remove()SaveChanges()。这个技巧是可行的,因为实体框架只需要知道删除的主键。

请注意以下关于此技巧的内容:

  • 这里调用的是方法Attach(),不是Add();否则,实体框架核心会将虚拟对象视为新对象。
  • 只有在实体框架核心中没有配置冲突检查时,这个技巧才有效。但是,如果模型设置为在保存时比较其他列的值,则必须用虚拟对象中的当前值填充这些值。否则,无法删除对象,并出现DbConcurrenyException
  public static void RemoveFlightWithKey()
  {
   Console.WriteLine(nameof(RemoveFlightWithKey));

   using (WWWingsContext ctx = new WWWingsContext())
   {
    // Create a dummy object
    var f = new Flight();
    f.FlightNo = 123456;

    Console.WriteLine($"After creation: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);

    // Append dummy object to context
    ctx.Attach(f);

    Console.WriteLine($"After attach: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);

    // Delete flight
    ctx.FlightSet.Remove(f);
    // or: ctx.Remove(f);

    Console.WriteLine($"After remove: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);

    try
    {
     var count = ctx.SaveChanges();
     if (count == 0) Console.WriteLine("Problem: No changes saved!");
     else Console.WriteLine("Number of saved changes: " + count);
     Console.WriteLine($"After saving: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);
    }
    catch (Exception ex)
    {
     Console.WriteLine("Error: " + ex.ToString());
    }
   }
  }

Listing 10-9Deleting a Flight Record More Efficiently with a Dummy Object

批量删除

Remove()方法不适合Delete from Flight where FlightNo> 10000中定义的批量删除,因为实体框架核心将在每种情况下为每个对象生成一个DELETE命令。实体框架核心没有认识到许多DELETE命令可以合并成一个命令。在这种情况下,您应该总是依赖经典技术(SQL 或存储过程),因为在这里使用Remove()会慢很多倍。另一个选项是扩展 EFPlus(参见第二十章)。

执行数据库事务

请注意以下关于数据库事务的要点:

  • 当您运行SaveChanges()时,Entity Framework Core 总是自动进行一个事务,这意味着在上下文中所做的所有更改都被持久化,或者都不被持久化。
  • 如果您需要一个跨越对SaveChanges()方法的多次调用的事务,您必须使用ctx.Database.BeginTransaction()Commit()Rollback()来完成。
  • System.Transactions.Transactions.TransactionScope在实体框架核心中尚不支持。实体框架核心 2.1 版将支持;参见附录 C 。

Tip

最好的交易是你避免的交易。事务总是对应用的性能、可伸缩性和健壮性产生负面影响。

例 1

以下示例显示了对一个Flight对象进行两次更改的事务,这两次更改分别由SaveChanges()独立保存:

  public static void ExplicitTransactionTwoSaveChanges()
  {

   Console.WriteLine(nameof(ExplicitTransactionTwoSaveChanges));
   using (var ctx = new WWWingsContext())
   {
    // Start transaction. Default is System.Data.IsolationLevel.ReadCommitted
    using (var t = ctx.Database.BeginTransaction(System.Data.IsolationLevel.ReadCommitted))
    {
     // Print isolation level
     RelationalTransaction rt = t as RelationalTransaction;
     DbTransaction dbt = rt.GetDbTransaction();
     Console.WriteLine("Transaction with Level: " + dbt.IsolationLevel);

     // Read data
     int FlightNr = ctx.FlightSet.OrderBy(x => x.FlightNo).FirstOrDefault().FlightNo;
     var f = ctx.FlightSet.Where(x => x.FlightNo == FlightNr).SingleOrDefault();

     Console.WriteLine("Before: " + f.ToString());

     // Change data and save
     f.FreeSeats--;
     var count1 = ctx.SaveChanges();
     Console.WriteLine("Number of saved changes: " + count1);

     //  Change data again and save
     f.Memo = "last changed at " + DateTime.Now.ToString();
     var count2 = ctx.SaveChanges();
     Console.WriteLine("Number of saved changes: " + count2);

     Console.WriteLine("Commit or Rollback? 1 = Commit, other = Rollback");
     var input = Console.ReadKey().Key;
     if (input == ConsoleKey.D1)
     { t.Commit(); Console.WriteLine("Commit done!"); }
     else
     { t.Rollback(); Console.WriteLine("Rollback done!"); }

     Console.WriteLine("After in RAM: " + f.ToString());
     ctx.Entry(f).Reload();
     Console.WriteLine("After in DB: " + f.ToString());
    }
   }
  }

例 2

以下示例显示了对表Booking ( insert a new booking)和表Flight(减少自由选择的数量)进行更改的事务。这里,事务通过一个上下文类的两个不同的上下文实例发生。如果两个不同的上下文类引用同一个数据库,也可以通过它们进行事务处理。

请注意以下几点:

  • 数据库连接是单独创建和打开的。
  • 该事务在此连接上打开。
  • 上下文实例不打开自己的连接,而是使用打开的连接。为此,数据库连接对象被传递到上下文类的构造函数中,并保存在那里。在OnConfiguring()中,这个数据库连接对象必须和UseSqlServer()或者类似的一起使用,而不是将连接字符串作为参数传递!
  • 实例化后,事务对象必须被传递给ctx.Database.UseTransaction()

Note

未能提前打开连接并将其传递给相关的上下文实例将导致以下运行时错误:“指定的事务与当前连接不相关联。只能使用与当前连接相关联的事务。

图 10-11 显示了输出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-11

Output of the previous example

  public static void ExplicitTransactionTwoContextInstances()
  {
 CUI.MainHeadline(nameof(ExplicitTransactionTwoContextInstances));

   // Open shared connection
   using (var connection = new SqlConnection(Program.CONNSTRING))
   {
    connection.Open();
    // Start transaction. Default is System.Data.IsolationLevel.ReadCommitted
    using (var t = connection.BeginTransaction(System.Data.IsolationLevel.ReadCommitted))
    {
     // Print isolation level
     Console.WriteLine("Transaction with Level: " + t.IsolationLevel);
     int flightNo;

     using (var ctx = new WWWingsContext(connection))
     {
      ctx.Database.UseTransaction(t);
      var all = ctx.FlightSet.ToList();

      var flight = ctx.FlightSet.Find(111);
      flightNo = flight.FlightNo;
      ctx.Database.ExecuteSqlCommand("Delete from booking where flightno= " + flightNo);
      var pasID = ctx.PassengerSet.FirstOrDefault().PersonID;

      // Create and persist booking
      var b = new BO.Booking();
      b.FlightNo = flightNo;
      b.PassengerID = pasID;
      ctx.BookingSet.Add(b);
      var count1 = ctx.SaveChanges();
      Console.WriteLine("Numer of bookings saved: " + count1);
     }

     using (var ctx = new WWWingsContext(connection))
     {
      ctx.Database.UseTransaction(t);

      // Change free seats and save
      var f = ctx.FlightSet.Find(flightNo);
      Console.WriteLine("BEFORE: " + f.ToString());
      f.FreeSeats--;
      f.Memo = "last changed at " + DateTime.Now.ToString();
      Console.WriteLine("AFTER: " + f.ToString());
      var count2 = ctx.SaveChanges();
      Console.WriteLine("Number of saved changes: " + count2);

      Console.WriteLine("Commit or Rollback? 1 = Commit, other = Rollback");
      var input = Console.ReadKey().Key;
      Console.WriteLine();
      if (input == ConsoleKey.D1)
      {t.Commit(); Console.WriteLine("Commit done!");}
      else
      {t.Rollback(); Console.WriteLine("Rollback done!");}

      Console.WriteLine("After in RAM: " + f.ToString());
      ctx.Entry(f).Reload();
      Console.WriteLine("After in DB: " + f.ToString());
     }
    }
   }
  }

使用更改跟踪器

内置于实体框架核心中的变更跟踪器监视连接到实体框架核心上下文的所有对象的变更,可以通过程序代码在任何时候进行查询。

获取对象的状态

因为实体框架核心与普通的旧 CLR 对象(POCOs)一起工作,这些对象具有实体对象而不是基类,并且实现接口,所以实体对象不知道它们的上下文类或状态。

要查询对象状态,不要询问实体对象本身,而是询问上下文类的ChangeTracker对象。ChangeTracker对象有一个Entry()方法,为给定的Entity对象返回一个关联的EntryObject<EntityType>。该对象拥有以下内容:

  • ChangeTracker对象有一个EntityState类型的State属性,它是一个枚举类型,值为AddedDeletedDetachedModifiedUnchanged
  • 在属性中,您可以找到一个以PropertyEntry对象形式的实体对象的所有属性的列表。每个PropertyEntry对象都有一个IsModified属性,指示属性是否被更改,以及旧值(OriginalValue和新值(CurrentValue)。
  • 使用EntryObject<EntityType>,您也可以通过使用Property方法指定一个 lambda 表达式来直接获得一个特定的PropertyEntry对象。
  • GetDatabaseValues()用于从数据库中获取对象的当前状态。

清单 10-10 中的子程序加载一个Flight(第一个)并修改这个Flight对象。在程序开始时,不仅为Flight对象本身创建了一个变量,还为EntryObject<Flight>创建了一个entryObj变量,为PropertyEntry对象创建了propObj

加载Flight后,entryObjpropObj首先被ChangeTracker对象的对象填充。实体对象处于Unchanged状态,FreeSeats属性返回IsModified False。然后对象在属性FreeSeats中被改变。实体对象现在处于Modified状态,FreeSeatsIsModified返回True

Note

重要的是从上下文的ChangeTracker对象中检索信息;EntryObject<Flight>PropertyEntry的实例不会随着实体对象的改变而自动更新,而是反映了检索时的当前状态。

因此,您还必须在从ChangeTracker对象调用SaveChanges()方法后第三次请求这些对象。在SaveChanges()之后,实体对象的状态再次变为Unchanged,属性FreeSeats返回IsModified False

该例程还循环遍历EntryObject<Flight>Properties属性,以使用数据库的旧值和新值以及当前值返回实体对象的所有修改属性。该值可使用EntryObject<Flight>中的GetDatabaseValues()方法确定。然后GetDatabaseValues()对数据库进行查询,并用数据库中的所有当前值填充一个PropertyValues列表。数据库中的这些值可能与实体框架核心知道的值不同,并且在OriginalValue属性中可见,另一个进程(或同一进程中的另一个实体框架核心上下文)同时保存了对记录的更改。在这种情况下,发生了数据冲突。图 10-12 显示了输出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-12

Output

  /// </summary>
  public static void ChangeTracking_OneObject()
  {
   CUI.MainHeadline(nameof(ChangeTracking_OneObject));

   Flight flight;
   EntityEntry<BO.Flight> entryObj;
   PropertyEntry propObj;

   using (var ctx = new WWWingsContext())
   {

    CUI.Headline("Loading Object...");
    flight = (from y in ctx.FlightSet select y).FirstOrDefault();

    // Access Change Tracker
    entryObj = ctx.Entry(flight);
    propObj = entryObj.Property(f => f.FreeSeats);
    Console.WriteLine(" Object state: " + entryObj.State);
    Console.WriteLine(" Is FreeSeats modified?: " + propObj.IsModified);

    CUI.Headline("Changing Object...");
    flight.FreeSeats--;

    // Access Change Tracker again
    entryObj = ctx.Entry(flight);
    propObj = entryObj.Property(f => f.FreeSeats);
    Console.WriteLine(" Object state: " + entryObj.State);
    Console.WriteLine(" Is FreeSeats modified?: " + propObj.IsModified);

    // Print old and new values
    if (entryObj.State == EntityState.Modified)
    {
     foreach (PropertyEntry p in entryObj.Properties)
     {
      if (p.IsModified)
       Console.WriteLine(" " + p.Metadata.Name + ": " + p.OriginalValue + "->" + p.CurrentValue +
                         " / State in database: " + entryObj.GetDatabaseValues()[p.Metadata.Name]);
     }
    }

    CUI.Headline("Save...");
    int count = ctx.SaveChanges();
    Console.WriteLine(" Number of changes: " + count);

    // Update of the Objects of the Change Tracker
    entryObj = ctx.Entry(flight);
    propObj = entryObj.Property(f => f.FreeSeats);
    Console.WriteLine(" Object state: " + entryObj.State);
    Console.WriteLine(" Is FreeSeats modified?: " + propObj.IsModified);
   }
  }

Listing 10-10Querying the Change Tracker for a Changed Object

列出所有已更改的对象

ChangeTracker对象不仅可以提供单个对象的信息,还可以提供它使用其Entries()方法监控的所有实体对象的列表。然后,您可以根据所需的状态过滤实体对象。

清单 10-11 中的例程修改三个航班,然后创建一个值为 123456 的航班(如果它还不存在的话)。如果Flight对象已经存在,它将被删除。此后,例程分别向ChangeTracker对象请求新的、已更改的和已删除的对象(清单 10-12 )。三套都是由Entries()提供的。使用Where()操作符将集合从 LINQ 过滤到对象。在这三种情况下,都会调用PrintChangedProperties()助手例程。但是只有在对象改变的情况下,它才提供一些输出。如果对象已被添加或删除,则各个属性被视为未更改。

图 10-13 和图 10-14 显示输出。

  public static void ChangeTracking_MultipleObjects()
  {
   CUI.MainHeadline(nameof(ChangeTracking_MultipleObjects));

   using (var ctx = new WWWingsContext())
   {
    var flightQuery = (from y in ctx.FlightSet select y).OrderBy(f4 => f4.FlightNo).Take(3);
    foreach (var flight in flightQuery.ToList())
    {
     flight.FreeSeats -= 2;
     flight.Memo = "Changed on " + DateTime.Now;
    }

    var newFlight = ctx.FlightSet.Find(123456);
    if (newFlight != null)
    {
     ctx.Remove(newFlight);
    }
    else
    {
     newFlight = new Flight();
     newFlight.FlightNo = 123456;
     newFlight.Departure = "Essen";
     newFlight.Destination = "Sydney";
     newFlight.AirlineCode = "WWW";
     newFlight.PilotId = ctx.PilotSet.FirstOrDefault().PersonID;
     newFlight.Seats = 100;
     newFlight.FreeSeats = 100;
     ctx.FlightSet.Add(newFlight);
    }
    CUI.Headline("New objects");
    IEnumerable<EntityEntry> neueObjecte = ctx.ChangeTracker.Entries().Where(x => x.State == EntityState.Added);
    if (neueObjecte.Count() == 0) Console.WriteLine("none");
    foreach (EntityEntry entry in neueObjecte)
    {
     CUI.Print("Object " + entry.Entity.ToString() + " State: " + entry.State, ConsoleColor.Cyan);
     ITVisions.EFCore.EFC_Util.PrintChangedProperties(entry);
    }

    CUI.Headline("Changed objects");
    IEnumerable<EntityEntry> geaenderteObjecte =
     ctx.ChangeTracker.Entries().Where(x => x.State == EntityState.Modified);
    if (geaenderteObjecte.Count() == 0) Console.WriteLine("none");
    foreach (EntityEntry entry in geaenderteObjecte)
    {
     CUI.Print("Object " + entry.Entity.ToString() + " State: " + entry.State, ConsoleColor.Cyan);
     ITVisions.EFCore.EFC_Util.PrintChangedProperties(entry);
    }

    CUI.Headline("Deleted objects");
    IEnumerable<EntityEntry> geloeschteObjecte = ctx.ChangeTracker.Entries().Where(x => x.State == EntityState.Deleted);
    if (geloeschteObjecte.Count() == 0) Console.WriteLine("none");
    foreach (EntityEntry entry in geloeschteObjecte)
    {
     CUI.Print("Object " + entry.Entity.ToString() + " State: " + entry.State, ConsoleColor.Cyan);
    }
    Console.WriteLine("Changes: " + ctx.SaveChanges());
   }
  }

Listing 10-11Querying the Change Tracker for Several Changed Objects

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-14

Second pass of the code: flight 123456 is deleted again

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-13

First run of the code: flight 123456 is added

  /// <summary>
  /// Lists the changed properties of an object, including the current database state
  /// </summary>
  /// <param name="entry"></param>
  public static void PrintChangedProperties(EntityEntry entry)
  {
   PropertyValues dbValue = entry.GetDatabaseValues();
   foreach (PropertyEntry prop in entry.Properties.Where(x => x.IsModified))
   {
    var s = "- " + prop.Metadata.Name + ": " +
     prop.OriginalValue + "->" +
     prop.CurrentValue +
     " State in the database: " + dbValue[prop.Metadata.Name];
    Console.WriteLine(s);
   }
  }
Listing 10-12Auxiliary Routine for Querying the Change Tracker

十一、防止冲突(并发)

在许多生产场景中,多个人或自动后台任务可能同时访问相同的记录。这可能会导致冲突,发生相互矛盾的数据更改。本章展示了如何在实体框架核心中检测和解决这种冲突。

看一下并发的历史

与其前身实体框架和底层基本技术 ADO.NET 一样,实体框架核心不支持阻止其他进程的数据记录读取访问。这是微软在 2005 年的一个深思熟虑的决定。NET 1.0 (2002),因为锁会导致很多性能问题。在的 alpha 版本中。NET 2.0 (2005)中,在当时的新类SqlResultSet中有这样一个锁函数的原型,但是这个类从未在. NET 的 RTM 版本中发布。

因此,在。NET 以及基于它的框架,比如实体框架,实体框架核心,只有所谓的乐观锁定。乐观锁定是一种委婉的说法,因为实际上在数据库管理系统和 RAM 中没有任何东西被阻塞。只能确保以后会注意到变更冲突。第一个想写更改的进程获胜。所有其他进程都无法写入,并将收到一条错误消息。为了实现这一点,WHERE条件中的UPDATEDELETE命令包含来自源记录的单个或多个值。

一个DataSet连同一个DataAdapter和一个CommandBuilder对象不仅查询一个UPDATEDELETE命令的WHERE子句中的一个或多个主键列,而且还从进程在读取记录时接收的当前进程值的角度查询所有具有旧值的列(参见清单 11-1 )。同时,如果另一个进程更改了任何单独的列,UPDATEDELETE命令不会在数据库管理系统中导致运行时错误;相反,它导致零个记录受到影响。这允许DataAdapter检测到存在变更冲突。

UPDATE [dbo]. [Flight]
SET [FlightNo] = @p1, [Departure] = @p2, [Strikebound] = @p3, [CopilotId] = @p4, [FlightDate] = @p5, [Flightgesellschaft] = @p6, [AircraftTypeID] = @p7, [FreeSeats] = @p8, [LastChange] = @p9, [Memo] = @p10, [NonSmokingFlight] = @p11, [PilotId] = @p12, [Seats] = @p13, [Price] = @p14, [Timestamp] = @p15, [destination] = @p16
WHERE (([FlightNo] = @p17) AND ((@p18 = 1 AND [Departure] IS NULL) OR ([Departure] = @p19)) AND ((@p20 = 1 AND [Expires] IS NULL) OR ( [Strikebound] = @p21)) AND ((@p22 = 1 AND [CopilotId] IS NULL) OR ([CopilotId] = @p23)) AND ([FlightDate] = @p24) AND ([Airline] = @p25) AND ((@p26 = 1 AND [aircraftID_ID] IS NULL) OR ([aircraft_type_ID] = @p27)) AND ((@p28 = 1 AND [FreeSeats] IS NULL) OR ([FreeSeats] = @p29)) AND ( [Lastchange] = @p30) AND ((@p31 = 1 AND [NonSmokingFlight] IS NULL) OR ([NonSmokingFlight] = @p32)) AND ([PilotId] = @p33) AND ([Seats] = @p34) AND ((@p35 = 1 AND [price] IS NULL) OR ([price] = @p36)) AND ((@p37 = 1 AND [destination] IS NULL) OR ([destination] = @p38)))
Listing 11-1Update Command, As Created by a SqlCommandBuilder for the Flight Table with the Primary Key FlightNo

默认情况下没有冲突检测

实体框架核心和实体框架一样,默认情况下根本不锁,即使使用乐观锁也不行。标准很简单“最后写的人赢。”清单 11-2 展示了如何在 RAM 中更改一个Flight对象,并通过实体框架核心用SaveChanges()持久化该更改。该程序代码向数据库管理系统发送以下 SQL 命令:

UPDATE [Flight] SET [FreeSeats] = @p0
WHERE [FlightNo] = @p1;
SELECT @@ ROWCOUNT;

可以看到,WHERE条件中只出现了主键FlightNo;列FreeSeats或其他列的旧值不出现。因此,该值是持久的,即使其他进程同时更改了该值。因此,航空公司可能会出现航班超额预订的情况。例如,如果只剩下两个空位,而两个进程(几乎)同时加载该信息,则这两个进程中的每一个都可以从剩余的空位中减去两个位置。然后,数据库中列FreeSeats中的状态为零。事实上,四名乘客被安排在两个座位上。那在飞机上会很紧!

虽然SaveChanges()打开了一个事务,但它仅适用于一个存储操作,因此不能防止数据更改冲突。然而,忽略冲突对于用户来说通常是不可行或不可接受的。幸运的是,您可以重新配置实体框架核心,就像您在它的前身实体框架中首先处理代码一样。

实体框架核心会注意到的唯一更改冲突是用另一个进程删除记录,因为在这种情况下,UPDATE命令会返回零个记录被更改的事实,然后实体框架核心会引发一个DbUpdateConcurrencyException错误。

  public static void Change.FlightOneProperty()
  {
   CUI.MainHeadline(nameof(ChangeFlightOneProperty));

   int FlightNr = 101;
   using (WWWingsContext ctx = new WWWingsContext())
   {

    // Load flight
    var f = ctx.FlightSet.Find(FlightNr);

    Console.WriteLine($"Before changes: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);

    // Change object in RAM
    f.FreeSeats -= 2;

    Console.WriteLine($"After changes: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);

    // Persist changes
    try
    {
     var count = ctx.SaveChanges();
     if (count == 0)
     {
      Console.WriteLine("Problem: No changes saved!");
     }
     else
     {
      Console.WriteLine("Number of saved changes: " + count);
      Console.WriteLine($"After saving: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! Zustand des Flight-Objekts: " + ctx.Entry(f).State);
     }
    }
    catch (Exception ex)
    {
     Console.WriteLine("Error: " + ex.ToString());
    }
   }
  }

Listing 11-2Changing a Flight Object

使用乐观锁定检测冲突

实体框架核心向数据库管理系统发送以下 SQL 命令:

UPDATE [Flight] SET [FreeSeats] = @p0
WHERE [FlightNo] = @p1 AND [FreeSeats] = @p2;
SELECT @@ROWCOUNT;

这里,FlightNo除了查询主键之外,还对FreeSeats列的旧值(读取时的原始值)进行查询。为了实现这种冲突检测,需要改变的不是程序代码,而是实体框架核心模型。

有两种方法可以配置模型。

  • 通过数据注释[ConcurrencyCheck]
  • 通过 Fluent API 中的IsConcurrencyToken()

清单 11-3 显示了实体类Flight的一个部分。这里,FreeSeats[ConcurrencyCheck]进行了注释,实体框架核心自动查询所有UPDATEDELETE命令的WHERE条件中的旧值。这是通过在实体框架核心上下文类的OnModelCreating()中的相应PropertyBuilder对象上调用IsConcurrencyToken()来实现的(参见清单 11-4 )。

public class Flight
{

  [Key]
  public int FlightNo {get; set; }

  [ConcurrencyCheck]
  public short? FreeSeats {get; set;}

[ConcurrencyCheck]
public decimal? Price {get; set; }
  public short? Seats { get; set; }

...
}

Listing 11-3Use of Data Annotation [ConcurrencyCheck]

public class WWWingsContext: DbContext
{
  public DbSet<Flight> FlightSet { get; set; }
...
  protected override void OnModelCreating (ModelBuilder builder)
  {
   Builder %Entity<Flight>().Property (f => f.FreeSeats).IsConcurrencyToken();
...
}
}
Listing 11-4Using IsConcurrencyToken( ) in the Fluent API

现在,对几个列运行冲突检查可能是有用的。例如,冲突检查也可以通过Flight对象的Price列来执行。就内容而言,这意味着如果Flight的价格已经改变,你不能改变座位的数量,因为这个预订将会以旧价格显示给用户。然后,您可以用[ConcurrencyCheck注释Price属性,或者将其添加到 Fluent API 中。

builder.Entity<Flight>().Property(x => x.FreeSeats).ConcurrencyToken();

下面的 SQL 命令在WHERE条件中包含三个部分,来自清单:

SET NOCOUNT ON;
UPDATE [Flight] SET [FreeSeats] = @p0
WHERE [FlightNo] = @p1 AND [FreeSeats] = @p2 AND [Price] = @p3;
SELECT @@ ROWCOUNT;

检测所有属性的冲突

对于所有实体类和所有持久属性,通过数据注释或 Fluent API 进行这种配置可能会很乏味。幸运的是,Entity Framework Core 允许您进行大量配置。清单 11-5 展示了如何从ModelBuilder对象中使用OnModelCreating()中的ModelGetEntityTypes()通过GetProperties()获取所有实体类的列表以及每个实体类中的所有属性,从而在那里设置IsConcurrencyToken = true

public class WWWingsContext: DbContext
{
  public DbSet<Flight> FlightSet { get; set; }
...
  protected override void OnModelCreating (ModelBuilder builder)
  {
   foreach (IMutableEntityType entity in modelBuilder.Model.GetEntityTypes())
   {
    // get all properties
    foreach (var prop in entity.GetProperties())
    {
      prop.IsConcurrencyToken = true;
    }
   }
...
  }
}
Listing 11-5Mass Configuration of the ConcurrencyToken for All Properties in All Entity Classes

然后,清单创建了一个 SQL 命令,其中包含了WHERE条件中的所有列,如下所示:

SET NOCOUNT ON;
UPDATE [Flight] SET [FreeSeats] = @p0
WHERE [FlightNo] = @p1 AND [Departure] = @p2 AND [Destination] = @p3 AND [CopilotId] = @p4 AND [FlightDate] = @p5 AND [Airline] = @p6 AND [AircraftTypeID] IS NULL AND [ FreeSeats] = @p7 AND [LastChange] = @p8 AND [Memo] = @p9 AND [NonSmokingFlight] IS NULL AND [PilotId] = @p10 AND [Seats] = @p11 AND [Price] = @p12 AND [Strikebound] = @p13;
SELECT @@ ROWCOUNT;

通过惯例解决冲突

如果您想排除个别列,这也是可能的。在这种情况下,定义一个单独的注释是有意义的,称为[ConcurrencyNoCheckAttribute](参见清单 11-6 ),然后注释实体类的所有持久属性,实体框架核心不应该对这些属性执行冲突检查。清单 11-7 显示了考虑注释[ConcurrencyNoCheck]的示例的扩展。这里重要的是PropertyInfo后的零传播算子?.;这很重要,因为您可以在实体框架核心中定义所谓的影子属性,这些属性只存在于实体框架核心模型中,而不存在于实体类中。这些 shadow 属性没有PropertyInfo对象,所以在 shadow 属性没有空传播操作符的情况下,会出现常见的Null Reference运行时错误。使用ConcurrencyNoCheckAttribute,您可以根据需要从冲突检查中优雅地排除单个属性。

using system;
namespace EFCExtensions
{
/// <summary>
/// Annotation for EFCore entity classes and properties for which EFCore should not run a concurrency check
/// </ summary>
[AttributeUsage (AttributeTargets.Property | AttributeTargets.Class, AllowMultiple = false)]
public class ConcurrencyNoCheckAttribute: Attributes
{
}
}
Listing 11-6Annotation for Entity Class Properties for Which Entity Framework Core Should Not Run a Concurrency Check

public class WWWingsContext: DbContext
{
  public DbSet<Flight> FlightSet {get; set; }
...
  protected override void OnModelCreating (ModelBuilder builder)
  {
   // Get all entity classes
   foreach (IMutableEntityType entity in modelBuilder.Model.GetEntityTypes())
   {
    // get all properties
    foreach (var prop in entity.GetProperties())
    {
     // Look for annotation [ConcurrencyNoCheck]
     var annotation = prop.PropertyInfo?.GetCustomAttribute<ConcurrencyNoCheckAttribute>();
     if (annotation == null)
     {
      prop.IsConcurrencyToken = true;
     }
     else
     {
      Console.WriteLine("No Concurrency Check for" + prop.Name);
     }
     if (prop.Name == "Timestamp")
     {
      prop.ValueGenerated = ValueGenerated.OnAddOrUpdate;
      prop.IsConcurrencyToken = true;
     }
     foreach (var a in prop.GetAnnotations())
     {
      Console.WriteLine(prop.Name + ":" + a.Name + "=" + a.Value);
     }
    }
   }
}
...
}
}
Listing 11-7Mass Configuration of the ConcurrencyToken for All Properties in All Entity Classes, Except the Properties Annotated with [ConcurrencyNoCheck]

单独设置冲突检查

有时,在实践中,希望在逐案例例的基础上为各个属性的各个更改激活或停用冲突检查。不幸的是,这无法实现,因为数据注释是编译的,而且每个进程只调用一次OnModelCreating()。遗憾的是,在OnModelCreating()结束后,您无法更改实体框架核心模型。虽然DbContext类像ModelBuilder类一样提供了属性模型,但是在ModelBuilder中,属性模型具有IMutalModel类型(顾名思义,这是一个变量)。DbContext只获取IModel类型,而IsConcurrencyToken像许多其他属性一样是只读的。因此,如果您想逐个更改乐观锁定列,您需要自己向数据库管理系统发送UPDATEDELETE命令(通过实体框架核心或其他方式)。

添加时间戳

可以引入一个额外的时间戳列,而不是在单个数据列级别进行原始值比较。您可以在 Microsoft SQL Server 中找到一个这样的列,类型为rowversion ( https://docs.microsoft.com/en-us/sql/t-sql/data-types/rowversion-transact-sql ),称为timestamp(参见图 11-1 和图 11-2 )。对于每个单独的数据记录变化,它由数据库管理系统自动增加。因此,在使用UPDATEDELETE命令的情况下,只需检查该值是否仍为加载期间存在的先前值。如果是这样,整个记录保持不变。如果没有,则另一个进程至少更改了部分记录。然而,使用timestamp列,您无法区分变更相关的列和变更不相关的列。数据库管理系统在每次列改变时调整时间戳;不可能有例外。

Note

虽然目前 SQL Server Management Studio (SSMS)仍然显示旧名称timestamp,但是 Visual Studio 2016 中的 SQL Server Data Tools 显示了当前名称rowversion

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11-1

A timestamp column for a record in Microsoft SQL Server 2017 as shown in Visual Studio 2017

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11-2

A timestamp column for a record in Microsoft SQL Server 2017 as shown in SQL Server Management Studio 17.1

要使用时间戳进行变更冲突检测,您需要向实体类添加一个byte-数组列(byte[]),并用[timestamp]对其进行注释。然而,列的名称与实体框架核心无关。

[Timestamp]
public byte[] Timestamp { get; set; }

或者,您可以使用 Fluent API 再次进行设置,但这发生在程序代码中,如下所示:

builder.Entity<Flight>()
           .Property(p => p.Timestamp)
           .ValueGeneratedOnAddOrUpdate()
           .IsConcurrencyToken();

从实体框架核心 1.1 版本开始,也可以使用IsRowVersion()作为替代,如下图:

modelBuilder.Entity<Flight>().Property(x => x.Timestamp).IsRowVersion();

Note

每个表只能有一个timestamp / rowversion列。不幸的是,错误消息“一个表只能有一个时间戳列。”仅在您调用Update-Database时发生,不与Add-Migration一起发生。

对于时间戳支持,您不需要实现其他任何东西。如果在对象模型中有这样的属性,并且在数据库表中有相应的列,那么对于WHERE条件中的所有DELETEUPDATE命令,实体框架核心总是引用先前的时间戳值。

SET NOCOUNT ON;
UPDATE [Flight] SET [FreeSeats] = @p0
WHERE [FlightNo] = @p1 AND [Timestamp] IS NULL;
SELECT [Timestamp]
FROM [Flight]
WHERE @@ ROWCOUNT = 1 AND [FlightNo] = @p1;

如您所见,Entity Framework Core 还使用SELECT [Timestamp]来重新加载数据库管理系统在UPDATE之后更改的时间戳,以相应地更新 RAM 中的对象。如果没有发生这种情况,那么对象的第二次更新将是不可能的,因为这样 RAM 中的时间戳将会过时,并且即使没有任何更改冲突,Entity Framework Core 也将总是报告更改冲突(因为第一次更改是更改数据库表中时间戳的那个更改)。

按照惯例,时间戳配置也可以自动化。清单 11-8 中所示的批量配置自动为所有带有名称timestamp的属性添加时间戳,用于冲突检测。

public class WWWingsContext: DbContext
{
  public DbSet<Flight> FlightSet {get; set; }
  ...
  protected override void OnModelCreating (ModelBuilder builder)
  {
   // Get all entity classes
   foreach (IMutableEntityType entity in modelBuilder.Model.GetEntityTypes())
   {
    // Get all properties
    foreach (var prop in entity.GetProperties())
    {
     if (prop.Name == "Timestamp")
     {
      prop.ValueGenerated = ValueGenerated.OnAddOrUpdate;
      prop.IsConcurrencyToken = true;
     }

    }
   }
...
}
}

Listing 11-8Automatically Turning Any Properties Called Timestamp into Timestamps for Conflict Detection

解决冲突

本节说明如何验证冲突检测。图 11-3 显示了程序两次启动时的一些典型输出。首先启动 ID 为 10596 的进程,然后启动进程 18120。两人都读到了编号为 101 的航班,该航班目前还有 143 个座位。然后,过程 10596 将位置数减少 5,并在FreeSeats列中保持 138。现在进程 18120 为两个人预订了一个房间,所以在 RAM 中该值变为 141 FreeSeats。然而,进程 18120 不能持续,因为实体框架核心由于对FreeSeats列的冲突检测或基于时间戳列抛出了类型为DbUpdateConcurrencyException的错误。过程 18120 中的用户被给予接受或覆盖其他用户的改变或者抵消这两个改变的选择,这在某些情况下可能是有意义的。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11-3

Conflict detection and conflict resolution

清单 11-9 显示了实现。SaveChanges()接住了DbUpdateConcurrencyException。在错误处理程序中,PrintChangedProperties()助手函数用于指定在此过程中航班的哪些属性发生了更改,以及当前的数据库状态是什么。您通过方法GetDatabaseValues()获得数据库的当前状态,该方法向数据库管理系统发送相应的 SQL 查询。之后,用户必须做出决定。如果用户选择将更改应用到另一个进程,则在实体框架核心 API 中调用Reload()方法,该方法丢弃 RAM 中已更改的对象,并从数据库中重新加载它。如果用户选择覆盖对其他进程的更改,程序代码会稍微复杂和间接一些。命令链从数据库加载当前状态,并在实体框架核心变更跟踪器中将其设置为对象的原始值:ctx.Entry(Flight).OriginalValues.SetValues(ctx.Entry(Flight).GetDatabaseValues())。之后,SaveChanges()再次被调用,现在它可以工作了,因为在WHERE条件中使用的原始值或时间戳对应于数据库中数据记录的当前状态。然而,从理论上讲,如果在GetDatabaseValues()SaveChanges()之间的短时间内,另一个进程更改了数据库表中的记录,冲突可能会再次发生。因此,您应该封装SaveChanges()和相关的错误处理,但是为了更好地说明这个例子,这里没有这样做。

在图 11-3 中,用户选择了第三个选项,清除了两个变更。除了它的原始值和当前值,过程 18120 还需要列FreeSeats的当前数据库值。结果是正确的 136。但是,计算假设两个过程都从相同的原始值开始。如果没有进程间通信,进程 18120 可能不知道进程 10596 的种子值。账单只在特殊情况下有效。

当然,也可以让用户在(图形)用户界面中通过输入值而不是决定哪一方来解决冲突。就实现而言,清除就像另一个值的输入一样,对应于第二种情况,换句话说,覆盖另一个过程的更改。在调用SaveChanges()之前,简单地在对象中设置你想要随后在数据库中拥有的值(参见清单 11-9 和清单 11-10 中的案例ConsoleKey.D3)。

public static void ConflictWhileChangingFlight()
  {
   CUI.MainHeadline(nameof(ConflictWhileChangingFlight));
   Console.WriteLine("Process.ID=" + Process.GetCurrentProcess().Id);
   Console.Title = nameof(ConflictWhileChangingFlight) + ": Process-ID=" + Process.GetCurrentProcess().Id;

   // Flight, where the conflict should arise
   int flightNo = 151;

   using (WWWingsContext ctx = new WWWingsContext())
   {
    // --- load flight
    Flight flight = ctx.FlightSet.Find(flightNo);
    Console.WriteLine(DateTime.Now.ToLongTimeString() + ": free seats Before: " + flight.FreeSeats);

    short seats = 0;
    string input = "";
    do
    {
     Console.WriteLine("How many seats do you need at this flight?");
     input = Console.ReadLine(); // wait (time to start another process)
    } while (!Int16.TryParse(input, out seats));

    // --- change the free seats
    flight.FreeSeats -= seats;
    Console.WriteLine(DateTime.Now.ToLongTimeString() + ": free seats NEW: " + flight.FreeSeats);

    try
    {
     // --- try to save
     EFC_Util.PrintChangedProperties(ctx.Entry(flight));
     var count = ctx.SaveChanges();
     Console.WriteLine("SaveChanges: Number of saved changes: " + count);
    }
    catch (DbUpdateConcurrencyException ex)
    {
     Console.ForegroundColor = ConsoleColor.Red;
     CUI.PrintError(DateTime.Now.ToLongTimeString() + ": Error: Another user has already changed the flight!");

     CUI.Print("Conflicts with the following properties:");
     EFC_Util.PrintChangedProperties(ex.Entries.Single());

     // --- Ask the user
     Console.WriteLine("What do you want to do?");
     Console.WriteLine("Key 1: Accept the values of the other user");
     Console.WriteLine("Key 2: Override the values of the other user");
     Console.WriteLine("Key 3: Calculate new value from both records");

     ConsoleKeyInfo key = Console.ReadKey();
     switch(key.Key)
     {
      case ConsoleKey.D1: // Accept the values of the other user
       {
       Console.WriteLine("You have chosen: Option 1: Accept");
       ctx.Entry(flight).Reload();
       break;
      }
      case ConsoleKey.D2: // Override the values of the other user
       {
       Console.WriteLine("You have chosen: Option 2: Override");
       ctx.Entry(flight).OriginalValues.SetValues(ctx.Entry(flight).GetDatabaseValues());
       // wie RefreshMode.ClientWins bei ObjectContext
       EFC_Util.PrintChangeInfo(ctx);
       int count = ctx.SaveChanges();
       Console.WriteLine("SaveChanges: Saved changes: " + count);
       break;
      }
      case ConsoleKey.D3: // Calculate new value from both records
       {

        Console.WriteLine("You have chosen: Option 3: Calculate");
        var FreeSeatsOrginal = ctx.Entry(flight).OriginalValues.GetValue<short?>("FreeSeats");
        var FreeSeatsNun = flight.FreeSeats.Value;
        var FreeSeatsInDB = ctx.Entry(flight).GetDatabaseValues().GetValue<short?>("FreeSeats");
        flight.FreeSeats = (short) (FreeSeatsOrginal -
                            (FreeSeatsOrginal - FreeSeatsNun) -
                            (FreeSeatsOrginal - FreeSeatsInDB));
        EFC_Util.PrintChangeInfo(ctx);
        ctx.Entry(flight).OriginalValues.SetValues(ctx.Entry(flight).GetDatabaseValues());
        int count = ctx.SaveChanges();
        Console.WriteLine("SaveChanges: Saved changes: " + count);
        break;
       }
     }
    }
    Console.WriteLine(DateTime.Now.ToLongTimeString() + ": free seats after: " + flight.FreeSeats);

    // --- Cross check the final state in the database
    using (WWWingsContext ctx2 = new WWWingsContext())
    {
     var f = ctx.FlightSet.Where(x => x.FlightNo == flightNo).SingleOrDefault();
     Console.WriteLine(DateTime.Now.ToLongTimeString() + ": free seats cross check: " + f.FreeSeats);

    } // End using-Block -> Dispose()
   }
  }

Listing 11-9Conflict Detection and Conflict Resolution with Entity Framework Core

/// <summary>
  /// Print all changed objects and the changed properties
  /// </summary>
  /// <param name="ctx"></param>
  public static void PrintChangeInfo(DbContext ctx)
  {
   foreach (EntityEntry entry in ctx.ChangeTracker.Entries())
   {
    if (entry.State == EntityState.Modified)
    {
     CUI.Print(entry.Entity.ToString() + " Object state: " + entry.State, ConsoleColor.Yellow);
     IReadOnlyList<IProperty> listProp = entry.OriginalValues.Properties;
     PrintChangedProperties(entry);
    }
   }
  }

  /// <summary>
  /// Print the changed properties of an object, including the current database state
  /// </summary>
  /// <param name="entry"></param>
  public static void PrintChangedProperties(EntityEntry entry)
  {
   PropertyValues dbValue = entry.GetDatabaseValues();
   foreach (PropertyEntry prop in entry.Properties.Where(x => x.IsModified))
   {
    var s = "- " + prop.Metadata.Name + ": " +
     prop.OriginalValue + "->" +
     prop.CurrentValue +
     " State in the database: " + dbValue[prop.Metadata.Name];
    Console.WriteLine(s);
   }
  }

Listing 11-10Subroutines for Listing 11-9

实体框架核心上的悲观锁定

虽然微软故意没有在。NET 和。NET Core 可以用来阻止其他人对记录的读访问,但是我经常遇到一些客户,他们仍然迫切地想从一开始就避免冲突。使用 LINQ 命令,即使激活了事务,读锁也是不可行的。您需要一个事务和一个特定于数据库管理系统的 SQL 命令。在 Microsoft SQL Server 中,这是与事务相关联的查询提示SELECT ... WITH (UPDLOCK)。这个查询提示确保读记录被锁定,直到事务完成。它只在一个事务中工作,所以你会在清单 11-11 中找到一个ctx.Database.BeginTransaction()方法,然后是对commit()的调用。清单还展示了 Entity Framework Core 提供的FromSql()方法的使用,它允许您将自己的 SQL 命令发送到数据库管理系统,并将结果具体化为实体对象。

public static void UpdateWithReadLock()
 {
  CUI.MainHeadline(nameof(UpdateWithReadLock));
  Console.WriteLine("--- Change flight");
  int flightNo = 101;
  using (WWWingsContext ctx = new WWWingsContext())
  {
   try
   {
    ctx.Database.SetCommandTimeout(10); // 10 seconds
    // Start transaction
    IDbContextTransaction t = ctx.Database.BeginTransaction(IsolationLevel.ReadUncommitted); // default is System.Data.IsolationLevel.ReadCommitted
    Console.WriteLine("Transaction with Level: " + t.GetDbTransaction().IsolationLevel);

    // Load flight with read lock using  WITH (UPDLOCK)
    Console.WriteLine("Load flight using SQL...");
    Flight f = ctx.FlightSet.FromSql("SELECT * FROM dbo.Flight WITH (UPDLOCK) WHERE flightNo = {0}", flightNo).SingleOrDefault();

    Console.WriteLine($"Before changes: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);

    Console.WriteLine("Waiting for ENTER key...");
    Console.ReadLine();

    // Change object in RAM
    Console.WriteLine("Change flight...");
    f.FreeSeats -= 2;

    Console.WriteLine($"After changes: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);

    // Send changes to DBMS
    Console.WriteLine("Save changes...");
    var c = ctx.SaveChanges();
    t.Commit();
    if (c == 0)
    {
     Console.WriteLine("Problem: No changes saved!");
    }
    else
    {
     Console.WriteLine("Number of saved changes: " + c);
     Console.WriteLine($"After saving: Flight #{f.FlightNo}: {f.Departure}->{f.Destination} has {f.FreeSeats} free seats! State of the flight object: " + ctx.Entry(f).State);
    }
   }
   catch (Exception ex)
   {
    CUI.PrintError("Error: " + ex.ToString());
   }
  }
 }

Listing 11-11A Lock Is Already Set Up When Reading the Data Record

图 11-4 提供了事实上只要第一个进程还没有完成它的事务,第二个进程就不能读取FlightNo 101 的证据。在这个示例代码中,处理在事务中间等待用户输入。事务中的用户输入当然是“最糟糕的实践”,不应该出现在生产代码中。然而,在示例代码中,它是一个有用的工具,可以在几秒钟内模拟一个事务的运行时,直到另一个进程超时。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11-4

If the program code runs twice in parallel, in the second run the process will time out because the first process has a read lock on the record Note

我必须重申,数据库管理系统中的这种数据记录锁不是一种好的做法。锁,尤其是读锁,会降低应用的速度,还会很快导致死锁,即进程相互等待,从而无法再进行处理。这种做法损害了软件的性能、可伸缩性和稳定性。为什么我要在这一章展示它?因为我知道有些开发商还是要的。

顺便说一句,在数据库管理系统中记录锁的一个更好的替代方法是在应用级别使用锁,其中应用管理锁,也可能使用 RAM 中的自定义锁表。这样做的好处是,应用可以准确地呈现给当前正在处理记录的用户。比如用户 Müller 可以说“这个记录还有 4 分 29 秒由你独家编辑。”迈耶夫人说,“米勒先生正在处理这个数据集。他还有 4 分 29 秒来保存记录。在此期间,您不能对此记录进行任何更改。这提供了更多的可能性。例如,您可以让按钮显示“我是明星,现在我想立即将米勒先生从唱片中踢出去。”然而,在实体框架核心中,没有预定义的应用级锁定机制。这里需要你自己的创造力!

十二、日志

在传统的实体框架中,有两种简单的方法来获取或映射器发送给数据库的 SQL 命令。

  • 可以对查询对象(IQueryable<T>)调用ToString()
  • 您可以使用Log属性(从实体框架 6.0 版开始),就像在ctx.Database.Log = Console.WriteLine;中一样。

不幸的是,这两个选项在实体框架核心中都不可用。

以下命令

var query = ctx.FlightSet.Where(x => x.FlightNo > 300).OrderBy(x => x.- Date).Skip(10).Take(5);
Console.WriteLine (query.ToString());

仅提供以下输出:Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryable1 [BO.Flight]`。

ctx.Database对象在实体框架核心中没有log属性。

使用扩展方法 Log()

登录实体框架核心是可能的,但是比它的前身要复杂得多。因此,我以DbContext类的Log()扩展方法的形式创建了对Database对象的扩展。它是一个方法,而不是属性,因为在。不幸的是,只有扩展方法,没有扩展属性。

Note

本书的一些清单中使用了Log()方法。

你可以像这样使用方法Log():

using (var ctx1 = new WWWingsContext())
   {
    var query1 = ctx1.FlightSet.Where(x => x.FlightNo > 100).OrderBy(x => x.Date).Skip(10).Take(5);
    ctx1.Log(Console.WriteLine);
    var flightSet1 = query1.ToList();
    flightSet1.ElementAt(0).FreeSeats--;
    ctx1.SaveChanges();
   }

类似于实体框架中的Log属性,Log()是一个没有返回值的方法;它需要一个字符串作为唯一的参数。与经典的实体框架不同,您可以省略Log()中的参数。然后输出会自动打印到青色的Console.WriteLine()(见图 12-1 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12-1

Default logging for the Log( ) method

using (var ctx2 = new WWWingsContext())
   {
    var query2 = ctx2.FlightSet.Where(x => x.FlightNo < 3000).OrderBy(x => x.Date).Skip(10).Take(5);
    ctx2.Log();
    var flightSet2 = query2.ToList();
    flightSet2.ElementAt(0).FreeSeats--;
    ctx2.SaveChanges();
   }

如果您想记录到一个文件,您可以通过编写一个带有字符串参数且没有返回值的方法,并将其传递给Log()来实现。

using (var ctx3 = new WWWingsContext())
   {
    Console.WriteLine("Get some flights...");
    var query3 = ctx3.FlightSet.Where(x => x.FlightNo > 100).OrderBy(x => x.Date).Skip(10).Take(5);
    ctx3.Log(LogToFile);
    var flightSet3 = query3.ToList();
    flightSet3.ElementAt(0).FreeSeats--;
    ctx3.SaveChanges();
   }
  }

  public static void LogToFile(string s)
  {
   Console.WriteLine(s);
   var sw = new StreamWriter(@"c:\temp\log.txt");
   sw.WriteLine(DateTime.Now + ": " + s);
   sw.Close();
  }

默认情况下,Log()方法只记录那些发送到 DBMS 的命令。Log()方法的另外两个参数影响日志记录的数量,如下所示:

  • 参数 2 是日志类别(字符串)的列表。
  • 参数 3 是事件编号(数字)的列表。

下一个命令将打印来自实体框架核心的所有日志输出(它为每个命令生成大量屏幕输出):

ctx1.Log(Console.WriteLine, new List<string>(), new List<int>());

下一个命令将只打印某些日志类别和事件号:

ctx1.Log(Console.WriteLine, new List<string>() { "Microsoft.EntityFrameworkCore.Database.Command" }, new List<int>() { 20100, 20101});

事件 20100 是Executing,20101 是Executed

Note

因为实体框架核心不是将内部使用的记录器工厂类分配给一个上下文实例,而是分配给所有上下文实例,所以在特定实例上建立的日志记录方法也适用于同一上下文类的其他实例。

实现 Log()扩展方法

列表 12-1 展示了扩展方法Log()的实现。

  • Log()扩展方法向ILoggerFactory服务添加一个记录器提供者的实例。
  • logger provider 是一个实现ILoggerProvider的类。在这个类中,实体框架核心为每个日志记录类别调用一次CreateLogger()
  • CreateLogger()然后必须为每个记录类别提供一个记录器实例。
  • 记录器是一个实现ILogger的类。
  • 清单 12-1 有一个FlexLogger类,它向Log()指定的方法发送一个字符串。如果没有指定方法,则调用ConsoleWriteLineColor()
  • 第二个 logger 类是NullLogger,它丢弃与 SQL 输出无关的所有日志类别的日志输出。
// Logging for EF Core
// (C) Dr. Holger Schwichtenberg, www.IT-Visions.de 2016-2017

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Reflection;

namespace ITVisions.EFCore
{
 /// <summary>
 /// Enhancement for the DbContext class for easy logging of the SQL commands sent by EF Core to a method that expects a string (C) Dr. Holger Schwichtenberg, www.IT-Visions.de
 /// </summary>
 public static class DbContextExtensionLogging
 {
  public static Dictionary<string, ILoggerProvider> loggerFactories = new Dictionary<string, ILoggerProvider>();

  public static bool DoLogging = true;
  public static bool DoVerbose = true;
  private static Version VERSION = new Version(4, 0, 0);

  private static List<string> DefaultCategories = new List<string>
  {
   "Microsoft.EntityFrameworkCore.Storage.IRelationalCommandBuilderFactory", // für EFCore 1.x
   "Microsoft.EntityFrameworkCore.Database.Sql", // für EFCore 2.0Preview1
   "Microsoft.EntityFrameworkCore.Database.Command", // für EFCore >= 2.0Preview2
  };

  private static List<int> DefaultEventIDs = new List<int>
  {
   20100 // 20100 = "Executing"
 };

  public static void ClearLog(this DbContext ctx)
  {
   var serviceProvider = ctx.GetInfrastructure<IServiceProvider>();
   // Add Logger-Factory
   var loggerFactory = serviceProvider.GetService<ILoggerFactory>();
   (loggerFactory as LoggerFactory).Dispose();
  }

  /// <summary>
  /// Extension Method for Logging to a method expecting a string
  /// </summary>
  /// <example>Log() or Log(Console.WriteLine) for console logging</example>
  public static void Log(this DbContext ctx, Action<string> logMethod = null, List<string> categories = null, List<int> eventsIDs = null, bool verbose = false)
  {
   DbContextExtensionLogging.DoVerbose = verbose;
   if (eventsIDs == null) eventsIDs = DefaultEventIDs;
   if (categories == null) categories = DefaultCategories;
   var methodName = logMethod?.Method?.Name?.Trim();
   if (string.IsNullOrEmpty(methodName)) methodName = "Default (Console.WriteLine)";

   if (DbContextExtensionLogging.DoVerbose)
   {
    Console.WriteLine("FLEXLOGGER EFCore " + VERSION.ToString() + " (C) Dr. Holger Schwichtenberg 2016-2017 " + methodName);
    Console.WriteLine("FLEXLOGGER Start Logging to " + methodName);
    Console.WriteLine("FLEXLOGGER Event-IDs: " + String.Join(";", eventsIDs));
    Console.WriteLine("FLEXLOGGER Categories: " + String.Join(";", categories));
   }
   // Make sure we only get one LoggerFactory for each LogMethod!
   var id = ctx.GetType().FullName + "_" + methodName.Replace(" ", "");
   if (!loggerFactories.ContainsKey(id))
   {
    if (verbose) Console.WriteLine("New Logger Provider!");
    var lp = new FlexLoggerProvider(logMethod, categories, eventsIDs);
    loggerFactories.Add(id, lp);
    // Get ServiceProvider
    var serviceProvider = ctx.GetInfrastructure();
    // Get Logger-Factory
    var loggerFactory = serviceProvider.GetService<ILoggerFactory>();
    // Add Provider to Factory
    loggerFactory.AddProvider(lp);
   }

  }
 }

 /// <summary>
 /// LoggerProvider for FlexLogger (C) Dr. Holger Schwichtenberg www.IT-Visions.de
 /// </summary>
 public class FlexLoggerProvider : ILoggerProvider
 {

 public Action<string> _logMethod;
  public List<int> _eventIDs = null;
  public List<string> _categories = null;

  public FlexLoggerProvider(Action<string> logMethod = null, List<string> categories = null, List<int> eventIDs = null)
  {
   _logMethod = logMethod;
   _eventIDs = eventIDs;
   _categories = categories;
   if (_eventIDs == null) _eventIDs = new List<int>();
   if (_categories == null) _categories = new List<string>();
  }

  /// <summary>
  /// Constructor is called for each category. Here you have to specify which logger should apply to each category
  /// </summary>
  /// <param name="categoryName"></param>
  /// <returns></returns>
  public ILogger CreateLogger(string categoryName)
  {
   if (_categories == null || _categories.Count == 0 || _categories.Contains(categoryName))
   {
    if (DbContextExtensionLogging.DoVerbose) Console.WriteLine("FLEXLOGGER CreateLogger: " + categoryName + ": Yes");
    return new FlexLogger(this._logMethod, this._eventIDs);
   }
   if (DbContextExtensionLogging.DoVerbose) Console.WriteLine("FLEXLOGGER CreateLogger: " + categoryName + ": No");
   return new NullLogger(); // return NULL nicht erlaubt :-(
  }

  public void Dispose()
  { }

  /// <summary>
  /// Log output to console or custom method
  /// </summary>
  private class FlexLogger : ILogger
  {
   private static int count = 0;

   readonly Action<string> logMethod;
   readonly List<int> _EventIDs = null;
   public FlexLogger(Action<string> logMethod, List<int> eventIDs)
   {
    count++;
    this._EventIDs = eventIDs;
    if (logMethod is null) this.logMethod = ConsoleWriteLineColor;
    else this.logMethod = logMethod;
   }

   private static void ConsoleWriteLineColor(object s)
   {
    var farbeVorher = Console.ForegroundColor;
    Console.ForegroundColor = ConsoleColor.Cyan;
    Console.WriteLine(s);
    Console.ForegroundColor = farbeVorher;
   }

   public bool IsEnabled(LogLevel logLevel) => true;

   private static long Count = 0;

   public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
   {
    if (!DbContextExtensionLogging.DoLogging) return;

    if (Assembly.GetAssembly(typeof(Microsoft.EntityFrameworkCore.DbContext)).GetName().Version.Major == 1 || (this._EventIDs != null && (this._EventIDs.Contains(eventId.Id) || this._EventIDs.Count == 0)))
    {
     Count++;
     string text = $"{Count:000}:{logLevel} #{eventId.Id} {eventId.Name}:{formatter(state, exception)}";
     // Call log method now
     logMethod(text);
    }
   }

   public IDisposable BeginScope<TState>(TState state)
   {
    return null;
   }
  }

  /// <summary>
  /// No Logging
  /// </summary>
  private class NullLogger : ILogger
  {
   public bool IsEnabled(LogLevel logLevel) => false;

   public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
   { }

   public IDisposable BeginScope<TState>(TState state) => null;
  }
 }
}

Listing 12-1Entity Framework Core Extensions for Easy Logging

日志类别

不幸的是,微软在 Entity Framework Core 版本 1.x 和 2.0 之间更改了日志类别的名称。

以下是 Entity Framework Core 1.x 中的日志记录类别:

  • Microsoft.EntityFrameworkCore.Storage.Internal.SQLServerConnection
  • Microsoft.EntityFrameworkCore.Storage.IExecutionStrategy
  • Microsoft.EntityFrameworkCore.Internal.RelationalModelValidator
  • Microsoft.EntityFrameworkCore.Query.Internal.SqlServerQueryCompilationContextFactory
  • Microsoft.EntityFrameworkCore.Query.Translators expression.Internal.SqlServerCompositeMethodCallTranslator
  • Microsoft.EntityFrameworkCore.Storage.IRelationalCommandBuilderFactory
  • Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler
  • Microsoft.EntityFrameworkCore.DbContext

以下是实体框架核心 2.0 中的日志记录类别:

  • Microsoft.EntityFrameworkCore.Infrastructure
  • Microsoft.EntityFrameworkCore.Update
  • Microsoft.EntityFrameworkCore.Database.Transaction
  • Microsoft.EntityFrameworkCore.Database.Connection
  • Microsoft.EntityFrameworkCore.Model.Validation
  • Microsoft.EntityFrameworkCore.Query
  • Microsoft.EntityFrameworkCore.Database.Command

Log()扩展方法的实现考虑了这种变化;它还考虑了类别Microsoft.EntityFrameworkCore.Query,该类别有两个事件:Executing(事件 ID 20100)和Executed(事件 ID 20101)。Log()在标准系统中仅输出事件 ID 20100。但是类别和事件 id 可以通过Log()的参数来控制。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值