惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Google DeepMind News
Google DeepMind News
博客园_首页
aimingoo的专栏
aimingoo的专栏
IT之家
IT之家
美团技术团队
GbyAI
GbyAI
博客园 - 司徒正美
Recent Commits to openclaw:main
Recent Commits to openclaw:main
www.infosecurity-magazine.com
www.infosecurity-magazine.com
S
Security @ Cisco Blogs
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Jina AI
Jina AI
The Cloudflare Blog
O
OpenAI News
雷峰网
雷峰网
P
Proofpoint News Feed
Google DeepMind News
Google DeepMind News
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
F
Fortinet All Blogs
量子位
C
CERT Recently Published Vulnerability Notes
V
V2EX
J
Java Code Geeks
H
Help Net Security
小众软件
小众软件
H
Hacker News: Front Page
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
T
Tor Project blog
Y
Y Combinator Blog
Spread Privacy
Spread Privacy
Martin Fowler
Martin Fowler
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Scott Helme
Scott Helme
N
News | PayPal Newsroom
Vercel News
Vercel News
爱范儿
爱范儿
TaoSecurity Blog
TaoSecurity Blog
Webroot Blog
Webroot Blog
博客园 - 叶小钗
T
Threat Research - Cisco Blogs
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
N
Netflix TechBlog - Medium
宝玉的分享
宝玉的分享
G
GRAHAM CLULEY
MyScale Blog
MyScale Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
L
Lohrmann on Cybersecurity
阮一峰的网络日志
阮一峰的网络日志
V
Visual Studio Blog
S
Schneier on Security

博客园 - 东邪独孤

【ASP.NET Core】在 Windows Forms 中运行 Web 服务器 【EF Core】继承策略——TPC 【EF Core】继承策略——TPT 【EF Core】继承策略——TPH 【EF Core】使用自定义的值比较器 【EF Core】值转换器 【EF Core】直接更新数据 【EF Core】实体追踪——Entry中记录的数据 【EF Core】实体状态与变更追踪 【EF Core】将一个实体映射到多个表的正确方法 【EF Core】“Code First”方案下以编程方式生成迁移 【EF Core】“DB First”方案下用编程方式生成数据库模型代码 【EF Core】三种方法记录生成的 SQL 语句 【EF Core】未定义实体类的数据库模型 【EF Core】“多对多”关系与跳跃导航 【EF Core】FromExpression 方法有什么用? 【EF Core】通过 DbContext 选项扩展框架 【EF Core】框架底层的数据库连接管理 【EF Core】再谈普通实体关系与 Owned 关系的区别 【EF Core】实体类的依赖注入 【EF Core】优化后的模型 【EF Core】使用外部 Model 【EF Core】聊聊“复合”属性 【EF Core】为 DatabaseFacade 扩展“创建”与“删除”数据表功能 【EF Core】带主键实体与无主键实体 【EF Core】框架是如何识别实体类的属性和主键的
【EF Core】级联删除行为
东邪独孤 · 2026-06-28 · via 博客园 - 东邪独孤

DeleteBehavior枚举(位于 Microsoft.EntityFrameworkCore 命名空间)所定义的数据删除行为是争对存在相对关系的实体来说的。这个和数据库中表与表之间的关系一致。数据表之间是通过列引用实现的。假设 A 依赖 B,那么 A 中会存在一列或多列去引用 B 中的一列或多列(通常是主键)。在 A 中引用外部记录的列就成了外键。而删除行为描述的就是:当 B 被删除后,A 中的外键如何处理。

上面只是简单提一下数据库方面的知识,后面咱们会有例子。现在,咱们先看看 DeleteBehavior 枚举定义了啥。

public enum DeleteBehavior
{
    ClientNoAction,
    ClientSetNull,
    ClientCascade,
/*----------- 强力分割线 --------*/    
    Restrict,
    SetNull,
    Cascade,
    NoAction
}

如你所见,DeleteBehavior 枚举的成员可以分为两组。第一组是带 Client 开头的,就是客户端的意思。那客户端是谁?谁连接并操作数据库?是 EF Core,所以,它是客户端。因此,这一组的意思就是由 EF Core 来处理关系实体的级联删除行为。什么情况下会这样呢?先有数据库,然后根据数据库生成(编写)实体类,但数据库并未定义任何关系,由 EF Core 来管理关系。说大白话就是,数据库只建表和存储数据,并不定义其他对象。相信很多伙伴比较喜欢这种方案,老周可喜欢这样做了,数据库的作用只存数据,老周连存储过程都不写的。哪怕 SQL Server 有很多强大功能,老周只拿来建表,连视图都很少用。这样搞的好处是好维护,除非你那里有专人维护数据库,不然像老周这样,多数项目都是自己一手包办的,把很多数据库的处理逻辑搬到程序代码中,省了很多事。正因为经常这样,导致老周在写 SQL 方面比较菜。

现在有些 SQL 查询可以用 AI 生成,但你也别指望它是万能的,只是在赶时间的时候能省些功夫。一般查询的话 AI 是没啥问题,生成的语句也基本对的。除了这个,目前 AI 比较好用的就是在 CSS 上,其他(如 C++ 之属)除非是一些很“流行”或标准算法的代码,不然错误很多。有些代码甚至是全错的(说明没有训练过),毕竟模型再大也不可能应对实际应用。所以这玩意儿用来省省时间是可以的,全信它还不如信我是汉武帝。

曾有某新同事,就是懒得写一些初始化代码,就用 AI 帮他完成的初始化(C语言)。然后他很苦恼,因为他检查来检查去都没发现哪里错了,他经过多次生成代码问题依旧。烧录进单片机后无限 Reset。害得他装逼失败,被同事笑了两个星期(因为同事是女的,所以他要在她面前装逼,也是自作多情的小伙子)。最后他厚着脸皮来找老周。老周就帮他看了下代码,错误就在 memcpy 函数拷贝错了内存。估计这代码是从网上的多个源混合训练模型的,说不定是从谁的博文中偷的代码。不仅拷错了内存,还把别的内存破坏了。你会问:编译不报错?那肯定不报错了,语法上又没错。当然,这个你不能说是C语言的问题,C语言的强项就在这里,内存管理是直接的,而且权限都给了开发者的。好处是在性能的时候,你能做到极致。你有一把很锋利的宝刀,在高手看来那可是宝物,但是你练武的时候把自己的头砍了,那你不能怪这刀太锋利。

第二组就是不带 Client 开头的,自然是在数据库上定义外键约束。这个一般用在 Code First 方案,由 EF Core 生成 SQL 语句并创建数据库。当然,DB First 也可能的,比如你的表关系是数据库上定义的,而且也定义了外键约束。

老周写这一篇水文的目的就跟大伙伴说这个事,如何选择 DeleteBehavior 的值。

好,上面的内容比较无聊,相信大伙都看得天灵盖冒烟了。下面咱们搞些有趣的。这里以 SQL Server 数据库为例。为什么用它?因为方便,其他数据库没那么方便。所以一般老周写测试代码都会用 SQL Server。

建好数据库后,创建两个表。

CREATE TABLE [dbo].[tb_address] (
    [Id]       INT           IDENTITY (1, 1) NOT NULL,
    [city]     NVARCHAR (10) NOT NULL,
    [town]     NVARCHAR (20) NOT NULL,
    [zip_code] VARCHAR (8)   NULL
);

CREATE TABLE [dbo].[tb_stu] (
    [Id]      INT           IDENTITY (1, 1) NOT NULL,
    [name]    NVARCHAR (12) NOT NULL,
    [age]     INT           NOT NULL,
    [addr_id] INT           NULL
);

很简单的两个表,一个表示地址,一个表示学生。字段也是随便写的,你不用在意是啥含义。不过,你要注意 tb_stu 表的 addr_id 字段。你能如老周一样聪明,肯定猜到了,它就是外键。是的,该字段就是引用 tb_address 表的主键的。但在数据库上没有配置外键和其他约束,

咱们在数据表中放一些数据,以供测试。

image

image

可以看到,这两个表是一对一的关系,即一名学生只对应一个地址。比如,范统同学的 addr_id 为 3,对应 Id=3 的地址,即他来自乌龟市王八镇。

咱们可以用联表查询来一览两个表的关系。

select s.[name], a.city, a.town from tb_stu s
left join tb_address a
on s.addr_id = a.Id;

结果如下

image

好了,数据准备好了,下面完成客户端代码。关于实体映射,你嫌麻烦可以用 EF 工具来生成。因为代码不多,老周直接手写了。

先定义实体类。

public class Student
{
    public int Id { get; set; }
    public string Name { get; set; } = default!;
    public int Age { get; set; }
    public Address? Addr { get; set; }
}

public class Address
{
    public int Id { get; set; }
    public string City { get; set; } = default!;
    public string Town { get; set; } = default!;
    public string? ZipCode { get; set; }
}

很 Easy,不用解释了啊。然后是从 DbContext 类派生出咱们自己的上下文(不派生也行,先初始化 Options 然后 new DbContext)。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Address>(ent =>
    {
        ent.ToTable("tb_address");
        ent.Property(x => x.Id).HasColumnName("Id");
        ent.Property(y => y.City).HasColumnName("city").HasMaxLength(10).IsUnicode();
        ent.Property(j => j.ZipCode).HasColumnName("zip_code").HasMaxLength(8).IsUnicode(false);
        ent.Property(x => x.Town).HasColumnName("town").HasMaxLength(20);
        ent.HasKey(x => x.Id);
    });
    modelBuilder.Entity<Student>(ent =>
    {
        ent.ToTable("tb_stu");
        ent.Property(x => x.Id).HasColumnName("Id");
        ent.Property(a => a.Name).HasColumnName("name").HasMaxLength(12);
        ent.Property(s => s.Age).HasColumnName("age");
        // 影子属性
        ent.Property<int?>("AddrId").HasColumnName("addr_id");

        ent.HasOne(s => s.Addr)
            .WithOne()
            .HasForeignKey<Student>("AddrId")
            .HasPrincipalKey<Address>(a => a.Id)
            .OnDelete(DeleteBehavior.ClientSetNull)
            .IsRequired(false);
        ent.Navigation(s => s.Addr).AutoInclude();
    });
}

Student 实体的 Addr 属性是一个导航属性,引用 Address 实体实例。导航的含议就是从 Student 实体就能访问关系实体 Address。毕竟 EF Core 的核心思想是要面向对象的,肯定不能搞个外键来访问关系实体的。

配置的代码很简单,你可以不看,重点关注 HasOne 一段。

HasOne 表示从当前(Student)实体起,到 s.Addr 所导航的另一个实体(Address)的关系。啥关系?一个 Student 对一个 Address。

同理,随后 WithOne 的意思是从反向找关系,从 Address 到 Student 也是一个 Address 对着一个 Student 实体。由于 Address 实体中没有指向 Student 的导航属性,故 WithOne 方法不用传参。

因此,总结起来,Student -> Address 是一对一关系。

HasForeignKey 方法配置外键,一对一的外键方法特殊,它可以指定一个类型参数,代表外键所在的实体。因为一对一比较特别,它既可以A引用B,也可以B引用A。所以,一对一关系必须交代明白谁引用谁,EF Core 无法自动识别。在本例中,是 Student 引用 Address,因此外键应当在 Student 实体上。但我们代码中基本不会去访问外键,所以老周把外键 AddrId 属性定义为影子属性——类中不定义的属性,只在 EF Core 的更改跟踪中维护。

调用 AutoInclude 方法的用途是让 EF Core 在查询 Student 实体时自动加载 Addr 导航属性,即引用相关的 Address 对象。这个会生成联表查询,就不用在LINQ语句中调用 Include 方法了。

现在,咱们做一件事: 把 Id 为 2 的 Address 记录删除。

Address? theaddr = c.Set<Address>().FirstOrDefault(a => a.Id == 2);
if(theaddr != null)
{
    // 删除
    c.Remove(theaddr);
}
// 检测更改
c.ChangeTracker.DetectChanges();
// 打印跟踪信息
Console.WriteLine(c.ChangeTracker.ToDebugString());
// 提交更新
c.SaveChanges();

DetectChanges 是扫描实体并检测状态,这个在 SaveChanges 方法时也会调用。此处老周是为了打印实体状更新才这样做,实际使用时,不要调用 DetectChanges 方法。

运行一下,看看发生什么。

Address 是删除了,但 Student 中对应的外键没有设置为 NULL。这表明没达到咱们预期。

这是因为咱们只查询了 tb_address 表,EF Core 根本不知道 tb_stu 表中哪些行引用了 id=2 的地址信息,所以,EF Core 就无法进行级联处理。

修改为ID 为3的地址信息,它由 id=1 的学生记录引用。咱们要先查出 id=1 的 Student 记录,再通过 Addr 导航属性来获取对应的地址信息,然后删除。

Student? student = c.Set<Student>().FirstOrDefault(s => s.Id == 1);
if(student != null && student.Addr != null)
{
    // 删除
    c.Remove(student.Addr);
}
// 检测更改
c.ChangeTracker.DetectChanges();
// 打印跟踪信息
Console.WriteLine(c.ChangeTracker.ToDebugString());
// 提交更新
c.SaveChanges();

这一次结果正确。看看打印的实体状态变更。

Address {Id: 3} Deleted
    Id: 3 PK
    City: '乌龟市'
    Town: '王八镇'
    ZipCode: '912033'
Student {Id: 1} Modified
    Id: 1 PK
    AddrId: <null> FK Modified Originally 3
    Age: 23
    Name: '范统'
  Addr: <null>

id=3 的地址信息被删除,而引用了此记录的 Student 对象,AddrId 也从原来的 3 变为 null。生成的 SQL 语句既有 DELETE 语句也有 UPDATE 语句。

UPDATE [tb_stu] SET [addr_id] = @p0
      OUTPUT 1
      WHERE [Id] = @p1;

DELETE FROM [tb_address]
      OUTPUT 1
      WHERE [Id] = @p2;

image

如果把删除行为改为 ClientCascade,那么,Address 记录删除后,引用该记录的 Student 记录也会被删除。

ent.HasOne(s => s.Addr)
    .WithOne()
    .HasForeignKey<Student>("AddrId")
    .HasPrincipalKey<Address>(a => a.Id)
    .OnDelete(DeleteBehavior.ClientCascade)
    .IsRequired(false);

运行代码会看到,除了被删的 Address 实体,引用它的 Student 实体也标记了删除。

Address {Id: 3} Deleted
    Id: 3 PK
    City: '鸡腿市'
    Town: '牛奶镇'
    ZipCode: '256213'
Student {Id: 1} Deleted
    Id: 1 PK
    AddrId: 3 FK
    Age: 23
    Name: '范统'
  Addr: {Id: 3}

生成的SQL语句自然也会包含两条 DELETE FROM 语句。

DELETE FROM [tb_stu]
      OUTPUT 1
      WHERE [Id] = @p0;

DELETE FROM [tb_address]
      OUTPUT 1
      WHERE [Id] = @p1;

剩下的 ClientNoAction 行为就是当被引用的记录删除后,引用它的外键不会被删除,也不会设置为 NULL。就是啥也不做。这个就不演示了。

DeleteBehavior 枚举的不带 Client 开头的成员表示级联删除行为会在数据库中定义。

1、Code First 方案下,创建数据库时,EF Core 会自动生成包含外键约束的 SQL 语句;

2 、DB First 方案下,数据库已定义了外键约束。删除时如果违反规范会报错。

总的来说就是数据库一定要定义外键约束且设置了级联删除行为(或保留默认值)。

下面的示例咱们采用 Code First 方案,由数据库执行级联删除。示例用的是 SQLite 数据库,要添加以下 nuget 库。

Microsoft.EntityFrameworkCore.Sqlite

定义两个实体类。

// 暂时关闭 Nullable
#nullable disable

public class User
{
    public int Uid { get; set; }
    public string Name { get; set; }
    /// <summary>
    /// 导航属性
    /// </summary>
    public IEnumerable<ShareItem> Shares { get; set; }
}

public class ShareItem
{
    public int ShareID { get; set; }
    public string Title { get; set; }
    public string FileName { get; set; }
    public string? Comment { get; set; }
}

// 恢复 Nullable
#nullable enable

User 实体代表用户信息,ShareItem 代表用户分享的文件信息。User.Shares 是导航属性,即一个用户可以分享多个文件,所以,用户与分享文件信息之间是一对多的关系。

下面是配置模型,这里老周演示一下如何不继承 DbContext 来配置。

1、先创建 ModelBuilder 实例。这里注意,咱们不用直接调用构造函数,那样约定集合是空的,就无法使用 EF Core 内置的约定功能了,很多东西就要手动配置了。所以要用 SqliteConventionSetBuilder.CreateModelBuilder 静态方法。每个数据库都会提供这个方法的,比如 SqlServerConventionSetBuilder。这些类能够创建包含内置约定的 ModelBuilder。

ModelBuilder modelBuilder = SqliteConventionSetBuilder.CreateModelBuilder();

有了 ModelBuilder 实例,剩下的事情你应该全懂了,一样的配方,熟悉的家乡味。

2、配置数据库模型——添加实体。

// 添加实体
var entUser = modelBuilder.Entity<User>();
var entShareItem = modelBuilder.Entity<ShareItem>();
// 配置主键
entUser.HasKey(u => u.Uid).HasName("PK_UserID");
entShareItem.HasKey(s => s.ShareID).HasName("PK_ShareID");
// 外键存在于 ShareItem 中,用影子属性来存储
entShareItem.Property<int?>("userId");
// 表映射
entUser.ToTable("tb_users", tb =>
{
    tb.Property(u => u.Uid).HasColumnName("u_id");
    tb.Property(u => u.Name).HasColumnName("u_name");
});
entShareItem.ToTable("tb_shares", tb =>
{
    tb.Property(s => s.ShareID).HasColumnName("sh_id");
    tb.Property(s => s.Title).HasColumnName("sh_title");
    tb.Property(s => s.FileName).HasColumnName("sh_file");
    tb.Property(s => s.Comment).HasColumnName("sh_cmt");
    // 这个是影子属性映射的
    tb.Property("userId").HasColumnName("usr_id");
});
// 一对多关系
entUser.HasMany(u => u.Shares)
        .WithOne()
        .HasForeignKey("userId")
        .HasPrincipalKey(u => u.Uid)
        .OnDelete(DeleteBehavior.Cascade)
        .HasConstraintName("FK_ShareToUser");

由于是一对多关系,所以外键只能放在 ShareItem 实体上,调用 HasForeignKey 方法不必考虑类型参数了(人家也没声明类型参数)。OnDelete设置为级联删除。也就是:如果某个 User 对象被删了,那么,与它有关的 ShareItem 对象也要一起删除(株连三族)。

3、创建 DbContextOptionsBuilder 实例,用来配置 DbContext,如数据库连接字符串等。

// 准备调料(选项)
DbContextOptionsBuilder opbuilder = new DbContextOptionsBuilder();
// 使用啥数据库
opbuilder.UseSqlite("data source=shareMan.db");
// 需要日志
opbuilder.LogTo(msg =>
{
    Console.ForegroundColor = ConsoleColor.Green;
    Console.WriteLine(msg);
    Console.ResetColor();
},
(eventid, _) => eventid == RelationalEventId.CommandExecuted);

LogTo 调用的重载有两个委托。第一个是输入一个 string 参数,这个参数是日志内容,可以自定义记录方式。比如写到文件中,这里老周只写到控制台。第二个委托有两个输入参数:参数A是日志关联的 EventId,参数B是日志等级(调试、严重、警告、信息),返回值是 bool 类型,这个委托是个筛选器,返回 true 表示记录它;返回 false 表示不记录该条日志。这里只判断事件 RelationalEventId.CommandExecuted,如果是它就记录,其他事件拜拜。CommandExecuted 指的是执行 SQL 之后发生。这个配置的目的就是记录 SQL 语句。

4、很重要一步,新手最容易忘记。因为咱们现在不继承 DbContext 类,就不会重写 OnModelCreating 方法了,所以,你得让 DbContext 知道咱们用的数据库模型。所以要调用 UseModel 方法关联我们前面配置好的模型。

opbuilder.UseModel(modelBuilder.Model.FinalizeModel());

调用 FinalizeModel 方法是让模型固定(只读,不能改了)。

5、实例化DbContext对象,把选项传进构造函数。其他操作不变。

6、先动态创建一下数据库,存入些测试数据。

 using (DbContext ctx = new(opbuilder.Options))
 {
     bool done = ctx.Database.EnsureCreated();
     if(done)
     {
         User u1 = new()
         {
             Name = "吴淼水",
             Shares = [
                 new ShareItem{
                     Title = "大宋神探",
                     FileName = "神碳吴四水.docx",
                     Comment = "时代楷模"
                 }
             ]
         };

         User u2 = new()
         {
             Name = "高大上",
             Shares = new List<ShareItem>{
                     new ShareItem
                     {
                         Title = "想要飞得更高",
                         FileName = "励志王.pdf",
                         Comment = "笑死人"
                     },
                     new ShareItem
                     {
                         Title = "一氧化二氢居然有毒",
                         FileName = "2026智慧档案.doc",
                         Comment = "民用化学开山之祖"
                     }
             }
         };

         // 将两实体标记为 Added 状态
         ctx.AddRange(u1, u2);
         // 保存数据
         ctx.SaveChanges();
         Console.WriteLine("\n---------- 初始化后 ----------");
         PrintUsers(ctx.Set<User>().Include(u => u.Shares).ToArray());
     }
 }

访问 DbContextOptionsBuilder 对象的 Options 属性,你就能拿到已配置的选项了。

PrintUsers 是个自定义方法,用来打印 User 与跟它有关的 ShareItem 信息。

static void PrintUsers(IEnumerable<User> usrs)
{
    foreach(User u in usrs)
    {
        Console.WriteLine($"用户:{u.Name} ({u.Uid})");
        // 遍历导航属性
        foreach(ShareItem share in u.Shares)
        {
            Console.WriteLine($"\t{share.Title} | {share.FileName}");
        }
    }
    Console.WriteLine();
}

创建数据表的 SQL 如下:

CREATE TABLE "tb_users" (
          "u_id" INTEGER NOT NULL CONSTRAINT "PK_UserID" PRIMARY KEY AUTOINCREMENT,
          "u_name" TEXT NULL
      );

CREATE TABLE "tb_shares" (
          "sh_id" INTEGER NOT NULL CONSTRAINT "PK_ShareID" PRIMARY KEY AUTOINCREMENT,
          "sh_title" TEXT NULL,
          "sh_file" TEXT NULL,
          "sh_cmt" TEXT NULL,
          "usr_id" INTEGER NULL,
          CONSTRAINT "FK_ShareToUser" FOREIGN KEY ("usr_id") REFERENCES "tb_users" ("u_id") ON DELETE CASCADE
      );

数据库中在 tb_shares 表中已定义了外键约束,且级联删除。

8、我们现在删除一个用户。

using (DbContext ctx = new(opbuilder.Options))
{
    // 找出吴四水
    User? theuser = ctx.Set<User>()
        .Include(u => u.Shares)
        .FirstOrDefault(u => u.Name.Equals("吴淼水"));
    if(theuser != null)
    {
        // 把它删除
        ctx.Remove(theuser);
        // 保存
        ctx.SaveChanges();

        // 删除后
        Console.WriteLine("\n---------- 删除用户后 ----------");
        PrintUsers(ctx.Set<User>().Include(u => u.Shares).ToArray());
    }
}

Include 方法就是连同关系表一起查询。由于数据量小,咱们不用考虑笛卡尔乘积问题,所以就不必分开查询了。

生成的SQL如下:

DELETE FROM "tb_shares"
      WHERE "sh_id" = @p0
      RETURNING 1;

DELETE FROM "tb_users"
      WHERE "u_id" = @p0
      RETURNING 1;

对比一下,删除前后打印的数据。

---------- 初始化后 ----------
用户:吴淼水 (1)
        大宋神探 | 神碳吴四水.docx
用户:高大上 (2)
        想要飞得更高 | 励志王.pdf
        一氧化二氢居然有毒 | 2026智慧档案.doc

---------- 删除用户后 ----------
用户:高大上 (2)
        想要飞得更高 | 励志王.pdf
        一氧化二氢居然有毒 | 2026智慧档案.doc

哈哈,这个对比其实没B用,反正 User 已经没了一个,自然查不到 ShareItem 了。

好了,今天咱们就水到这里了。