2024年7月

AutoFixture
是一个.NET库,旨在简化单元测试中的数据设置过程。通过自动生成测试数据,它帮助开发者减少测试代码的编写量,使得单元测试更加简洁、易读和易维护。AutoFixture可以用于任何.NET测试框架,如xUnit、NUnit或MSTest。

默认情况下AutoFixture生成的字段值很多时候都满足不了测试需求,比如:

public class User
{
	public int Id { get; set; }
	public string Name { get; set; } = null!;
	[EmailAddress]
	public string? Email { get; set; }
	[StringLength(512)]
	public string? Address { get; set; }
	public DateTime CreatedAt { get; set; } = DateTime.Now;
}

如果直接使用
Create<T>()
生成的User对象,他会默认给你填充Id为随机整数,Name和Email为一串Guid,显然这里的邮箱地址生成就不能满足要求,并不是一个有效的邮箱格式

那么如何让AutoFixture按需生成有效的测试数据呢?方法其实有好几种:

方法1:直接定制
var fixture = new Fixture();
fixture.Customize<User>(c => c
    .With(x => x.Email, "特定值")
    .Without(x => x.Id));

这里,With方法用于指定属性的具体值,而Without方法用于排除某些属性不被自动填充。

方法2:使用匿名函数

这在需要对生成的数据进行更复杂的操作时非常有用。

var fixture = new Fixture();
fixture.Customize<User>(c => c.FromFactory(() => new User
{
    Email = "通过工厂方法生成",
}));
方法3:实现ICustomization接口

对于更复杂的定制需求,可以通过实现ICustomization接口来创建一个定制化类。这种方法的好处是可以重用定制逻辑,并且使得测试代码更加整洁。

public class MyCustomClassCustomization : ICustomization
{
    public void Customize(IFixture fixture)
    {
        fixture.Customize<User>(c => c
            .With(x => x.Email, "自定义值")
            .Without(x => x.Id));
    }
}
// 使用定制化
var fixture = new Fixture();
fixture.Customize(new MyCustomClassCustomization());
方法4:使用
Build<T>
方法

Build<T>
方法提供了一种链式调用的方式来定制类型的生成规则,这在只需要对单个对象进行简单定制时非常方便。

var myCustomObject = fixture.Build<User>()
                            .With(x => x.Email, $"{Guid.NewId()}@example.com")
                            .Without(x => x.Id)
                            .Create();

最佳实践:

这里以
xunit
测试框架为例,
我们需要提前引用
AutoFixture
,
AutoFixture.Xunit2
库,实现一个
UserAutoDataAttribute
类,继承自
InlineAutoDataAttribute
重写
GetData
方法,大致代码如下:

public  class UserAutoDataAttribute : InlineAutoDataAttribute
    {
        public UserAutoDataAttribute(params object[] values) : base(values)
        {
            ArgumentNullException.ThrowIfNull(values[0]);
        }

        public override IEnumerable<object[]> GetData(MethodInfo testMethod)
        {
            var fixture = new Fixture();
            //这里使用上面的4种方式的一种,亦或者根据自身情况定制!
            var user = fixture.Build<User>()
                 //.With(x => x.Id, 0)
                 .Without(x => x.Id) //ID需要排除因为EFCore需要插入时自动生成
                 .With(x => x.Email, $"{Uuid7.NewUuid7()}@example.com") //邮箱地址,需要照规则生成
                 .Create();
            yield return new object[] { Values[0], user };
        }
    }

下面是一个测试用例,需要填充db,和一个自动生成的User参数

public class UnitOfWorkTests(ITestOutputHelper output)
{
	[Theory]
	[UserAutoData(1)]
	[UserAutoData(2)]
	public async Task MyUnitOfWorkTest(int db, User user)
	{
		var services = new ServiceCollection();
		services.AddLogging();
		services.AddDbContext<TestDbContext>(options =>
		 {
                    options.UseInMemoryDatabase($"test-{db}");
		});
		services.AddUnitOfWork<TestDbContext>();

		var provider = services.BuildServiceProvider();
		var uow = provider.GetRequiredService<IUnitOfWork<TestDbContext>>();

		//add user
		await uow.GetRepository<User>().InsertAsync(user);
		await uow.SaveChangesAsync();

		// select user
		var user2 = await uow.GetRepository<User>().FindAsync(1);
		Assert.NotNull(user2);

		// delete user
		uow.GetRepository<User>().Delete(1);
		var row = await uow.SaveChangesAsync();

		Assert.Equal(1, row);

		// select user
		user2 = await uow.GetRepository<User>().GetFirstOrDefaultAsync(x => x.Id == 1);
		Assert.Null(user2);
	}
}

如果你已经习惯编写单元测试,但还没有使用
AutoFixture
,那么推荐你尝试一下,也许你也会喜欢上TA

NAS 通过提供多用户网络数据存取服务,极大地简化了数据共享和管理。而 NFS 作为实现这种共享的一种主流协议,尽管广泛应用,但在处理复杂的 AI 训练场景时常常受限于其性能和一致性问题。

JuiceFS 在最新的1.2版本中增加了直连 NFS 功能,这一创新允许 JuiceFS 直接利用 NAS 上的 NFS 服务,而无需预挂载。 通过 JuiceFS 的直连 NFS 功能,用户可以直接使用现有的 NAS 的存储空间创建 JuiceFS 文件系统,无需额外准备其他的对象存储。

1. 直连 NFS 存储的优势

  • 免预先挂载
    :直接使用 NFS 作为 JuiceFS 的底层存储,无需预先挂载,简化了配置和管理。
  • 高性能
    :JuiceFS 通过缓存和预读等技术,提升了 NFS 存储的性能,支持高并发读写。
  • 跨平台共享
    :JuiceFS 能够将 NFS 存储转换为分布式文件系统,实现了跨平台共享,不仅可以在 Linux、macOS、Windows 等操作系统上使用,还可以在 Hadoop、Kubernetes、Docker 等容器环境中使用。

2. JuiceFS 助力本地 AI 模型训练

借助 JuiceFS,用户可以将训练数据、模型文件等存储在现有的 NAS 上。借助 JuiceFS 的分布式、高性能、高可用的特性,用户可以在多个计算节点上同时访问这些数据,提升 AI 模型训练的效率。

在训练机上,用可以户通过 JuiceFS 挂载点、S3 Gateway、WebDAV、CSI Driver、Hadoop API 等多种方式访问 NAS 上的数据,JuiceFS 会自动缓存数据,提升训练的性能。

JuiceFS 支持多种缓存策略,可以根据不同的场景选择合适的缓存策略,提升训练的性能。例如,可以使用
--cache-size
参数设置缓存大小,使用
--cache-dir
参数指定缓存目录,使用 warmup 策略预读数据等。
更多关于 JuiceFS 的缓存策略,请参考
官方文档

3. 直连 NFS 创建 JuiceFS 文件系统

使用直连 NFS 存储创建 JuiceFS 文件系统的过程十分简单,只需在 NAS 或文件服务器上配置好 NFS 服务,然后在 JuiceFS 创建文件系统时指定 NFS 存储的地址即可。

例如,使用 NFSv3 协议的 NFS 存储,在相同网络内任何安装了 JuiceFS 客户端的计算机上,通过以下命令创建 JuiceFS 文件系统:

sudo juicefs format --storage nfs \
    --bucket 192.168.1.88:/data/nfs \
    redis://192.168.1.88/0 \
    myjfs

其中,
--storage nfs
指定了使用 NFS 存储,
--bucket
指定了 NFS 存储的地址,redis://192.168.1.88/0 指定了 Redis 作为元数据存储,myjfs 是文件系统的名称。

更多关于直连 NFS 存储的内容,请参考
官方文档

4. 注意事项

在使用 NFS 作为存储层创建 JuiceFS 文件系统时,需要注意以下几点:

  1. JuiceFS 暂不不支持 NFSv4 的身份认证机制,因此需要遵循 NFSv3 协议配置 NFS 存储,在创建文件系统时也无需指定
    --access-key

    --secret-key
  2. 为了充分发挥 JuiceFS 的缓存能力,建议在 JuiceFS 客户端所在机器上准备充足的高速 SSD 空间作为缓存设备,以提升性能。
  3. NFS 默认采用 root_squash 机制,它会将 root 身份执行的操作映射为
    nobody:nogroup
    ,因此在 NFS 服务器上需要配置好权限,确保 JuiceFS 客户端有权限访问 NFS 存储。

5. 总结

JuiceFS v1.2.0 版本新增的直连 NFS 存储功能,让 JuiceFS 可以更好的与 NAS 配合使用,提升了 JuiceFS 对 NFS 的兼容性,同时也为企业提供了更简易的存储解决方案。用户可以利用现有的存储资源在本地构建高性能、高可用的分布式文件系统,为 AI 模型训练、数据分析等场景提供更好的支持。

欢迎大家下载试用 JuiceFS v1.2.0 版本,体验直连 NFS 创建文件系统,为本地 AI 模型训练提供强大动力!

前言

数据库并发,数据审计和软删除一直是数据持久化方面的经典问题。早些时候,这些工作需要手写复杂的SQL或者通过存储过程和触发器实现。手写复杂SQL对软件可维护性构成了相当大的挑战,随着SQL字数的变多,用到的嵌套和复杂语法增加,可读性和可维护性的难度是几何级暴涨。因此如何在实现功能的同时控制这些SQL的复杂度是一个很有价值的问题。而且这个问题同时涉及应用软件和数据库两个相对独立的体系,平行共管也是产生混乱的一大因素。

EF Core作为 .NET平台的高级ORM框架,可以托管和数据库的交互,同时提供了大量扩展点方便自定义。以此为基点把对数据库的操作托管后便可以解决平行共管所产生的混乱,利用LINQ则可以最大程度上降低软件代码的维护难度。

由于项目需要,笔者先后开发并发布了通用的
基于EF Core存储的国际化服务

基于EF Core存储的Serilog持久化服务
,不过这两个功能包并没有深度利用EF Core,虽然主要是因为没什么必要。但是项目还需要提供常用的数据审计和软删除功能,因此对EF Core进行了一些更深入的研究。

起初有考虑过是否使用现成的ABP框架来处理这些功能,但是在其他项目的使用体验来说并不算好,其中充斥着大量上下文依赖的功能,而且这些依赖信息能轻易藏到和最终业务代码相距十万八千里的地方(特别是代码还是别人写的时候),然后在不经意间给你一个大惊喜。对于以代码正交性、非误导性,纯函数化为追求的一介码农(看过我发布的那两个功能包的朋友应该有感觉,一个功能笔者也要根据用途划分为不同的包,确保解决方案中的各个项目都能按需引用,不会残留无用的代码),实在是喜欢不起来ABP这种全家桶。

鉴于项目规模不大,笔者决定针对这些需求做一个专用功能,目标是尽可能减少依赖,方便将来复用到其他项目,降低和其他功能功能冲突的风险。现在笔者将用一系列博客做成果展示。由于这些功能没有经过大范围测试,不确定是否存在未知缺陷,因此暂不打包发布。

新书宣传

有关新书的更多介绍欢迎查看
《C#与.NET6 开发从入门到实践》上市,作者亲自来打广告了!
image

正文

由于这些功能设计的代码量和知识点较多,为控制篇幅,本文介绍树形查询功能。

SqlServer原生支持分层数据,EF Core也提供了相应的支持,但是很遗憾,这又是一个独占功能。为了兼容其他数据库只能单独处理。由于EF Core的导航修复功能,使用ParentId的自关联结构能得到原生支持。这也是描述一棵树最简单且不会破坏数据完整性的方式(即这种描述方式永远满足树结构的所有判定约束)。但是在查询方面,这种结构确并不方便,因此为了简化查询,出现了其他存储树的设计方式,常见的有左右值编码、路径描述和额外的关系描述表等。这些描述方式能在一定程度上简化查询,但是确无法在物理上确保数据完整性,这就对数据维护提出了严峻的挑战。

在中篇我们用视图实现了全自动的级联软删除模拟,那么是否同样可以用视图来解决树形结构的查询问题呢?答案是肯定的,而这只有一个小小的前提条件——支持公用表表达式(SQL中的递归)。这样就能实现物理表中使用ParentId的自关联确保数据完整性,同时自动兼容EF Core的导航修复。而用于简化查询的其他信息则由视图自动计算生成。

生成树的视图功能其实已经在本文宣传的书中实现了,不过这次新增自动软删除后,树视图也需要考虑如何兼容软删除。一开始笔者想过在一个视图定义中实现,后来发现这种方式开发难度比较大,而且不利于复用已有的研究成果。最终决定使用独立的视图,这就涉及到视图数据源的选择,因为EF Core只能映射一个视图。经过一番思考发现树形视图永远只依赖其自身的表或视图,因此EF Core映射到树形视图,属性视图依赖软删除视图是最简单方便的。在之前介绍软删除的文章中已经出现了和树有关的代码,这些代码的一部分用处就是选择映射目标。

代码实现

基础接口

/// <summary>
/// 树形数据接口
/// </summary>
/// <typeparam name="T">节点数据类型</typeparam>
public interface ITree<T>
{
    /// <summary>
    /// 父节点
    /// </summary>
    T? Parent { get; set; }

    /// <summary>
    /// 子节点集合
    /// </summary>
    IList<T> Children { get; set; }

    /// <summary>
    /// 节点深度,根的深度为0
    /// </summary>
    int Depth { get; }

    /// <summary>
    /// 是否是根节点
    /// </summary>
    bool IsRoot { get; }

    /// <summary>
    /// 是否是叶节点
    /// </summary>
    bool IsLeaf { get; }

    /// <summary>
    /// 是否有子节点
    /// </summary>
    bool HasChildren { get; }

    /// <summary>
    /// 节点路径(UNIX路径格式,以“/”分隔)
    /// </summary>
    string? Path { get; }
}

/// <summary>
/// 树形实体接口
/// </summary>
/// <typeparam name="T">实体类型</typeparam>
public interface ITreeEntity<T> : IEntity, ITree<T>
{
}

/// <summary>
/// 树形实体接口
/// </summary>
/// <typeparam name="TKey">主键类型</typeparam>
/// <typeparam name="TEntity">实体类型</typeparam>
public interface ITreeEntity<TKey, TEntity> : ITreeEntity<TEntity>, IEntity<TKey>
    where TKey : struct, IEquatable<TKey>
    where TEntity : ITreeEntity<TKey, TEntity>
{
    /// <summary>
    /// 父节点Id
    /// </summary>
    TKey? ParentId { get; set; }
}

/// <summary>
/// 实体接口
/// </summary>
public interface IEntity;

/// <summary>
/// 实体接口
/// </summary>
/// <typeparam name="TKey">唯一标识的类型</typeparam>
public interface IEntity<TKey> : IEntity
    where TKey : struct, IEquatable<TKey>
{
    /// <summary>
    /// 实体的唯一标识
    /// </summary>
    TKey Id { get; set; }
}

本文的
ITree<T>
接口就是从前文软删除视图操作排序用的接口简化而来。

模型配置扩展

/// <summary>
/// 树形实体模型配置扩展
/// </summary>
public static class TreeEntityModelBuilderExtensions
{
    private const string _queryViewAnnotationName = EntityModelBuilderExtensions._queryViewAnnotationName;

    /// <summary>
    /// 配置树形实体接口
    /// </summary>
    /// <typeparam name="TKey">主键类型</typeparam>
    /// <typeparam name="TEntity">树形实体类型</typeparam>
    /// <param name="builder">实体类型构造器</param>
    /// <param name="dummyValueSql">表用计算列的虚假值生成Sql</param>
    /// <returns>实体类型构造器</returns>
    public static EntityTypeBuilder<TEntity> ConfigureForITreeEntity<TKey, TEntity>(
        this EntityTypeBuilder<TEntity> builder,
        ITreeEntityDummyValueSql dummyValueSql
    )
        where TKey : struct, IEquatable<TKey>
        where TEntity : class, ITreeEntity<TKey, TEntity>
    {
        ArgumentNullException.ThrowIfNull(builder);

        builder.HasOne(e => e.Parent)
            .WithMany(pe => pe.Children)
            .HasForeignKey(e => e.ParentId);

        builder.Property(e => e.Depth)
            .HasComputedColumnSql(dummyValueSql.DepthSql);

        builder.Property(e => e.HasChildren)
            .HasComputedColumnSql(dummyValueSql.HasChildrenSql);

        builder.Property(e => e.Path)
            .HasComputedColumnSql(dummyValueSql.PathSql);

        ConfigQueryViewAnnotationForTreeEntity<TKey, TEntity>(builder);

        return builder;
    }

    /// <summary>
    /// 配置树形实体接口
    /// </summary>
    /// <param name="modelBuilder">模型构造器</param>
    /// <param name="dummyValueSql">表用计算列的虚假值生成Sql</param>
    /// <returns>模型构造器</returns>
    public static ModelBuilder ConfigureForITreeEntity(this ModelBuilder modelBuilder, ITreeEntityDummyValueSql dummyValueSql)
    {
        ArgumentNullException.ThrowIfNull(modelBuilder);
        ArgumentNullException.ThrowIfNull(dummyValueSql);

        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => e.ClrType.IsDerivedFrom(typeof(ITreeEntity<,>))))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
            var treeEntityMethod = GetEntityTypeConfigurationMethod(
                nameof(ConfigureForITreeEntity),
                2,
                entity.FindProperty(nameof(TreeType.Id))!.ClrType,
                entity.ClrType);

            treeEntityMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null), dummyValueSql]);
        }

        return modelBuilder;
    }

    /// <summary>
    /// 配置树形实体的查询视图注解
    /// </summary>
    /// <typeparam name="TKey">实体主键类型</typeparam>
    /// <typeparam name="TEntity">实体类型</typeparam>
    /// <param name="builder">实体类型构造器</param>
    private static void ConfigQueryViewAnnotationForTreeEntity<TKey, TEntity>(EntityTypeBuilder<TEntity> builder)
        where TKey : struct, IEquatable<TKey>
        where TEntity : class, ITreeEntity<TKey, TEntity>
    {
        var annotationValue = builder.Metadata.FindAnnotation(_queryViewAnnotationName)?.Value;
        if (annotationValue is null)
        {
            builder.HasAnnotation(_queryViewAnnotationName, new List<Type>() { typeof(ITreeEntity<,>) });
        }
        else
        {
            var stringListAnnotationValue = annotationValue as List<Type>;
            if (stringListAnnotationValue is not null && stringListAnnotationValue.Find(static x => x == typeof(ITreeEntity<,>)) is null)
            {
                stringListAnnotationValue.Add(typeof(ITreeEntity<,>));
            }
        }
    }
}

/// <summary>
/// 仅用于内部辅助,无实际作用
/// </summary>
file sealed class TreeType : ITreeEntity<int, TreeType>
{
    public TreeType()
    {
        throw new NotImplementedException();
    }

    public int? ParentId { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
    public TreeType? Parent { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
    public IList<TreeType> Children { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }

    public int Depth => throw new NotImplementedException();

    public bool IsRoot => throw new NotImplementedException();

    public bool IsLeaf => throw new NotImplementedException();

    public bool HasChildren => throw new NotImplementedException();

    public string? Path => throw new NotImplementedException();

    public int Id { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
}

Sql模版(以SqlServer为例)

/// <summary>
/// 树形实体的视图列在表中的临时值映射
/// <para>EF Core目前还不支持多重映射时分别配置表和视图的映射,因此需要在表中映射一个同名计算列</para>
/// </summary>
public interface ITreeEntityDummyValueSql
{
    /// <summary>
    /// 节点深度的SQL
    /// </summary>
    string DepthSql { get; }

    /// <summary>
    /// 节点是否有子树的SQL
    /// </summary>
    string HasChildrenSql { get; }

    /// <summary>
    /// 节点路径的SQL
    /// </summary>
    string PathSql { get; }
}

public class DefaultSqlServerTreeEntityDummyValueSql : ITreeEntityDummyValueSql
{
    public static DefaultSqlServerTreeEntityDummyValueSql Instance => new();

    private const string _depthSql = "-1";
    private const string _hasChildrenSql = "cast(0 as bit)";
    private const string _pathSql = "''";

    public string DepthSql => _depthSql;

    public string HasChildrenSql => _hasChildrenSql;

    public string PathSql => _pathSql;

    private DefaultSqlServerTreeEntityDummyValueSql() { }
}

/// <summary>
/// 树形实体的视图SQL模板
/// </summary>
public interface ITreeEntityDatabaseViewSqlTemplate : ITableOrColumnNameFormattable
{
    /// <summary>
    /// 创建视图的模板
    /// </summary>
    string CreateSqlTemplate { get; }

    /// <summary>
    /// 删除视图的模板
    /// </summary>
    string DropSqlTemplate { get; }
}

public class DefaultSqlServerTreeEntityViewSqlTemplate : ITreeEntityDatabaseViewSqlTemplate
{
    public static DefaultSqlServerTreeEntityViewSqlTemplate Instance => new();

    private const string _viewNameTemplate = $$"""{{EntityModelBuilderExtensions._treeQueryViewNamePrefixes}}{tableName}""";

    private const string _createSqlTemplate =
        $$"""
        --创建或重建树形实体查询视图
        {{_dropSqlTemplate}}
        CREATE VIEW {{_viewNameTemplate}}    --创建视图
        AS
        WITH [temp]({columns}, [Depth], [Path], [HasChildren]) AS
        (
            --初始查询(这里的 [ParentId] IS NULL 在数据中是最底层的根节点)
            SELECT {columns},
                0 AS [Depth],
                '/' + CAST([Id] AS nvarchar(max)) + '/' AS [Path], --如果Id使用Guid类型,可能会导致层数太深时出问题(大概100层左右,超过4000字之后的字符串会被砍掉,sqlserver 2005以后用 nvarchar(max)可以突破限制),Guid的字数太多了
                (CASE WHEN EXISTS(SELECT 1 FROM [{dataSourceName}] WHERE [{dataSourceName}].[ParentId] = [Root].[Id]) THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END) AS [HasChildren]
            FROM [{dataSourceName}] AS [Root]
            WHERE [Root].[ParentId] IS NULL

            UNION ALL
            --递归条件
            SELECT {child.columns},
                [Parent].[Depth] + 1,
                [Parent].[Path] + CAST([Child].[Id] AS nvarchar(max)) + '/' AS [Path],
                (CASE WHEN EXISTS(SELECT 1 FROM [{dataSourceName}] WHERE [{dataSourceName}].[ParentId] = [Child].[Id]) THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END) AS [HasChildren]
            FROM [{dataSourceName}] AS [Child] --3:这里的临时表和原始数据表都必须使用别名不然递归的时候不知道查询的是哪个表的列
            INNER JOIN [temp] AS [Parent]
            ON ([Child].[ParentId] = [Parent].[Id]) --这个关联关系很重要,一定要理解一下谁是谁的父节点
        )
        --4:递归完成后 一定不要少了这句查询语句 否则会报错
        SELECT *
        FROM [temp];
        GO
        """;

    private const string _dropSqlTemplate =
        $"""
        --删除可能存在的过时树形实体查询视图
        IF EXISTS(SELECT * FROM [sysobjects] WHERE [id] = OBJECT_ID(N'{_viewNameTemplate}') AND objectproperty(id, N'IsView') = 1)
        BEGIN
            DROP VIEW [{_viewNameTemplate}]
        END
        GO
        """;

    public string CreateSqlTemplate => _createSqlTemplate;

    public string DropSqlTemplate => _dropSqlTemplate;

    public string? FormatTableOrColumnName(string? name)
    {
        if(name is null) return null;

        return $"[{name}]";
    }

    private DefaultSqlServerTreeEntityViewSqlTemplate() { }
}

迁移扩展

/// <summary>
/// 树形实体视图迁移扩展
/// </summary>
public static class TreeEntityMigrationBuilderExtensions
{
    private static readonly ImmutableArray<string> _properties = ["Depth", "Path", "HasChildren"];

    /// <summary>
    /// 自动扫描迁移模型并配置树形实体查询视图
    /// </summary>
    /// <param name="migrationBuilder">迁移构造器</param>
    /// <param name="thisVersion">当前版本的迁移</param>
    /// <param name="previousVersion">上一个版本的迁移</param>
    /// <param name="isUp">是否为升级迁移</param>
    /// <param name="sqlTemplate">Sql模板</param>
    /// <returns>迁移构造器</returns>
    public static MigrationBuilder ApplyTreeEntityQueryView(
        this MigrationBuilder migrationBuilder,
        Migration thisVersion,
        Migration? previousVersion,
        bool isUp,
        ITreeEntityDatabaseViewSqlTemplate sqlTemplate)
    {
        ArgumentNullException.ThrowIfNull(migrationBuilder);
        ArgumentNullException.ThrowIfNull(thisVersion);
        ArgumentNullException.ThrowIfNull(sqlTemplate);

        var thisVersionEntityTypes = thisVersion.TargetModel.GetEntityTypes()
            .Where(static et =>
                (et.FindAnnotation(EntityModelBuilderExtensions._queryViewAnnotationName)?.Value as List<Type>)
                ?.Any(x => x == typeof(ITreeEntity<,>)) is true
            );

        var previousVersionEntityTypes = previousVersion?.TargetModel.GetEntityTypes()
            .Where(static et =>
                (et.FindAnnotation(EntityModelBuilderExtensions._queryViewAnnotationName)?.Value as List<Type>)
                ?.Any(x => x == typeof(ITreeEntity<,>)) is true
            );

        var pendingViewOperations = new List<(IEntityType? entity, string? tableName, bool isCreate)>();

        var tempViewOperationsDict = new Dictionary<string, List<(IEntityType? entity, string? tableName, bool isCreate)>>();
        foreach (var tableOperation in
            migrationBuilder.Operations.Where(static op =>
            {
                var opType = op.GetType();
                return opType.IsDerivedFrom<TableOperation>() || opType.IsDerivedFrom<DropTableOperation>();
            }))
        {
            if (tableOperation is CreateTableOperation createTable)
            {
                // 升级迁移创建表,同步创建视图
                if (isUp && thisVersionEntityTypes.Any(et => et.GetTableName() == createTable.Name))
                {
                    var entity = thisVersionEntityTypes.Single(en => en.GetTableName() == createTable.Name);
                    AddEntityCreateEntityViewToTempDict(tempViewOperationsDict, entity);
                }

                // 降级迁移创建表,如果上一个版本的模型是树形实体,用上一个版本的模型重建视图
                if (!isUp)
                {
                    EnsureMigrationOfPreviousVersion(previousVersion);
                    if (previousVersionEntityTypes!.Any(et => et.GetTableName() == createTable.Name) is true)
                    {
                        var entity = previousVersionEntityTypes!.Single(en => en.GetTableName() == createTable.Name);
                        AddEntityCreateEntityViewToTempDict(tempViewOperationsDict, entity);
                    }
                }
            }
            // 迁移操作修改表只对修改表名作出反应
            else if (tableOperation is AlterTableOperation alterTable)
            {
                // 升级迁移用当前版本的模型重建视图
                if (isUp)
                {
                    // 如果上一版本这个实体是树形实体,删除旧视图
                    if (previousVersionEntityTypes?.Any(en => en.GetTableName() == alterTable.OldTable.Name) is true)
                    {
                        pendingViewOperations.Add((null, alterTable.OldTable.Name, false));
                    }

                    if (thisVersionEntityTypes!.Any(en => en.GetTableName() == alterTable.Name))
                    {
                        var entity = thisVersionEntityTypes!.Single(en => en.GetTableName() == alterTable.Name);
                        AddEntityCreateEntityViewToTempDict(tempViewOperationsDict, entity);
                    }
                }
                // 回滚迁移用上一个版本的模型重建视图
                else
                {
                    // 如果当前版本这个实体是树形实体,删除旧视图
                    if (thisVersionEntityTypes.Any(en => en.GetTableName() == alterTable.OldTable.Name))
                    {
                        pendingViewOperations.Add((null, alterTable.OldTable.Name, false));
                    }

                    EnsureMigrationOfPreviousVersion(previousVersion);
                    if (previousVersionEntityTypes!.Any(en => en.GetTableName() == alterTable.Name))
                    {
                        var entity = previousVersionEntityTypes!.Single(en => en.GetTableName() == alterTable.Name);
                        AddEntityCreateEntityViewToTempDict(tempViewOperationsDict, entity);
                    }
                }
            }
            else if (tableOperation is DropTableOperation dropTable)
            {
                // 升级迁移删除表,如果在上一版本中这个实体是树形实体,删除视图
                if (isUp)
                {
                    EnsureMigrationOfPreviousVersion(previousVersion);
                    if (previousVersionEntityTypes!.Any(en => en.GetTableName() == dropTable.Name))
                    {
                        AddTableDropTableViewToTempDict(tempViewOperationsDict, dropTable.Name);
                    }
                }
                // 回滚迁移删除表,如果在当前版本中这个实体是树形实体,删除视图
                else if (thisVersionEntityTypes.Any(en => en.GetTableName() == dropTable.Name))
                {
                    AddTableDropTableViewToTempDict(tempViewOperationsDict, dropTable.Name);
                }
            }
        }

        foreach (var columnOperation in
            migrationBuilder.Operations.Where(static op =>
            {
                var opType = op.GetType();
                return opType.IsDerivedFrom<ColumnOperation>() || opType.IsDerivedFrom<DropColumnOperation>();
            }))
        {
            if (columnOperation is AddColumnOperation addColumn)
            {
                if (isUp && thisVersionEntityTypes!.Any(en => en.GetTableName() == addColumn.Table))
                {
                    var entity = thisVersionEntityTypes!.Single(en => en.GetTableName() == addColumn.Table);
                    AddEntityCreateEntityViewToTempDict(tempViewOperationsDict, entity);
                }

                if (!isUp)
                {
                    EnsureMigrationOfPreviousVersion(previousVersion);
                    if (previousVersionEntityTypes!.Any(en => en.GetTableName() == addColumn.Table))
                    {
                        var entity = previousVersionEntityTypes!.Single(en => en.GetTableName() == addColumn.Table);
                        AddEntityCreateEntityViewToTempDict(tempViewOperationsDict, entity);
                    }
                }
            }
            else if (columnOperation is AlterColumnOperation alterColumn/* && alterColumn.OldColumn.Name is not null && alterColumn.Name != alterColumn.OldColumn.Name*/)
            {
                if (isUp)
                {
                    if (thisVersionEntityTypes!.Any(et => et.GetTableName() == alterColumn.Table))
                    {
                        var entity = thisVersionEntityTypes!.Single(en => en.GetTableName() == alterColumn.Table);
                        AddEntityCreateEntityViewToTempDict(tempViewOperationsDict, entity);
                    }
                    else if (previousVersionEntityTypes?.Any(et => et.GetTableName() == alterColumn.Table) is true)
                    {
                        AddTableDropTableViewToTempDict(tempViewOperationsDict, alterColumn.Table);
                    }
                }
                else
                {
                    EnsureMigrationOfPreviousVersion(previousVersion);
                    if (previousVersionEntityTypes!.Any(en => en.GetTableName() == alterColumn.Table))
                    {
                        var entity = previousVersionEntityTypes!.Single(en => en.GetTableName() == alterColumn.Table);
                        AddEntityCreateEntityViewToTempDict(tempViewOperationsDict, entity);
                    }
                }
            }
            else if (columnOperation is DropColumnOperation dropColumn)
            {
                if (isUp)
                {
                    // 当前版本仍然是树形实体,说明被删除的列和树形无关,重建视图
                    if (thisVersionEntityTypes!.Any(et => et.GetTableName() == dropColumn.Table))
                    {
                        var entity = thisVersionEntityTypes!.Single(en => en.GetTableName() == dropColumn.Table);
                        AddEntityCreateEntityViewToTempDict(tempViewOperationsDict, entity);
                    }
                    // 被删除的列是树形相关列(上一版本的实体是树形,但当前版本不是),删除视图
                    else if (previousVersionEntityTypes?.Any(et => et.GetTableName() == dropColumn.Table) is true)
                    {
                        AddTableDropTableViewToTempDict(tempViewOperationsDict, dropColumn.Table);
                    }
                }

                if (!isUp)
                {
                    EnsureMigrationOfPreviousVersion(previousVersion);
                    // 上一版本是树形实体,说明被删除的列和树形无关,重建视图
                    if (previousVersionEntityTypes?.Any(et => et.GetTableName() == dropColumn.Table) is true)
                    {
                        var entity = previousVersionEntityTypes.Single(en => en.GetTableName() == dropColumn.Table);
                        AddEntityCreateEntityViewToTempDict(tempViewOperationsDict, entity);
                    }
                    // 被删除的列是树形(上一版本的实体不是树形,但当前版本是),删除视图
                    else if (thisVersionEntityTypes?.Any(et => et.GetTableName() == dropColumn.Table) is true)
                    {
                        AddTableDropTableViewToTempDict(tempViewOperationsDict, dropColumn.Table);
                    }
                }
            }
        }

        // 聚合所有操作,然后选择其中合理的一个作为最终操作
        foreach (var entityViewOperations in tempViewOperationsDict)
        {
            Debug.Assert(entityViewOperations.Value.All(x => x.isCreate == entityViewOperations.Value.First().isCreate));
            if (isUp)
            {
                // 如果当前版本的实体确实是树形实体,选择创建视图的命令
                if ((thisVersionEntityTypes
                        ?.SingleOrDefault(et => et.GetTableName() == entityViewOperations.Key)
                        ?.FindAnnotation(EntityModelBuilderExtensions._queryViewAnnotationName)?.Value as List<Type>)
                        ?.Any(x => x == typeof(ITreeEntity<,>)) is true)
                {
                    pendingViewOperations.Add(entityViewOperations.Value.First(o => o.entity is not null && o.isCreate));
                }
                else
                {
                    pendingViewOperations.Add(entityViewOperations.Value.First(o => !o.isCreate));
                }
            }
            else
            {
                // 当前迁移就是第一版,选择删除视图命令
                if (previousVersion is null)
                {
                    pendingViewOperations.Add(entityViewOperations.Value.First(o => !o.isCreate));
                }
                // 如果上一版本的实体确实是树形实体,选择创建视图的命令
                else if ((previousVersionEntityTypes
                            ?.Single(et => et.GetTableName() == entityViewOperations.Key)
                            .FindAnnotation(EntityModelBuilderExtensions._queryViewAnnotationName)?.Value as List<Type>)
                            ?.Any(x => x == typeof(IDependencyLogicallyDeletable)) is true)
                {
                    pendingViewOperations.Add(entityViewOperations.Value.First(o => o.entity is not null && o.isCreate));
                }
                else
                {
                    pendingViewOperations.Add(entityViewOperations.Value.First(o => !o.isCreate));
                }
            }
        }

        foreach (var (entity, tableName, isCreate) in pendingViewOperations)
        {
            if (isCreate) migrationBuilder.CreateTreeEntityQueryView(entity!, sqlTemplate);
            else if (entity is not null) migrationBuilder.DropTreeEntityQueryView(entity, sqlTemplate);
            else if (tableName is not null) migrationBuilder.DropTreeEntityQueryView(tableName, sqlTemplate);
            else throw new InvalidOperationException("迁移实体类型和迁移表名不能同时为 null。");
        }

        return migrationBuilder;

        /// <summary>
        /// 确保提供了上一版本的迁移
        /// </summary>
        static void EnsureMigrationOfPreviousVersion(Migration? previousVersion)
        {
            if (previousVersion is null) throw new InvalidOperationException($"回滚操作指出存在更早版本的迁移,但未提供上一版本的迁移。");
        }

        /// <summary>
        /// 向按表分组的临时操作存放字典添加创建实体视图命令
        /// </summary>
        static void AddEntityCreateEntityViewToTempDict(Dictionary<string, List<(IEntityType? entity, string? tableName, bool isCreate)>> tempViewOperationsDict, IEntityType entity)
        {
            if (!tempViewOperationsDict.TryGetValue(entity.GetTableName()!, out var result))
            {
                result ??= [];
                tempViewOperationsDict.Add(entity.GetTableName()!, result);
            }
            result.Add((entity, null, true));
        }

        /// <summary>
        /// 向按表分组的临时操作存放字典添加删除表视图命令
        /// </summary>
        static void AddTableDropTableViewToTempDict(Dictionary<string, List<(IEntityType? entity, string? tableName, bool isCreate)>> tempViewOperationsDict, string tableName)
        {
            if (!tempViewOperationsDict.TryGetValue(tableName, out var result))
            {
                result ??= [];
                tempViewOperationsDict.Add(tableName, result);
            }
            result.Add((null, tableName, false));
        }
    }

    /// <summary>
    /// 创建树形实体查询视图
    /// </summary>
    /// <param name="migrationBuilder">迁移构造器</param>
    /// <param name="entityType">实体类型</param>
    /// <param name="sqlTemplate">Sql模板</param>
    /// <returns>迁移构造器</returns>
    public static MigrationBuilder CreateTreeEntityQueryView(
        this MigrationBuilder migrationBuilder,
        IEntityType entityType,
        ITreeEntityDatabaseViewSqlTemplate sqlTemplate)
    {
        ArgumentNullException.ThrowIfNull(migrationBuilder);
        ArgumentNullException.ThrowIfNull(entityType);
        ArgumentNullException.ThrowIfNull(sqlTemplate);

        var isTreeEntity = (entityType
            .FindAnnotation(EntityModelBuilderExtensions._queryViewAnnotationName)?.Value as List<Type>)
            ?.Any(static x => x == typeof(ITreeEntity<,>)) is true;

        if (!isTreeEntity) throw new InvalidOperationException($"{entityType.Name}不是树形实体或未配置视图生成。");

        var isDependencyLogicallyDeletableEntity = (entityType
            .FindAnnotation(EntityModelBuilderExtensions._queryViewAnnotationName)?.Value as List<Type>)
            ?.Any(static x => x == typeof(IDependencyLogicallyDeletable)) is true;

        var tableName = entityType.GetTableName()!;
        var dataSourceName = isDependencyLogicallyDeletableEntity
            ? $"{EntityModelBuilderExtensions._queryViewNamePrefixes}{tableName}"
            : tableName;

        var tableIdentifier = StoreObjectIdentifier.Table(tableName);

        var columnNames = entityType.GetProperties()
            .Where(static c => !_properties.Contains(c.Name))
            .Select(pro => sqlTemplate.FormatTableOrColumnName(pro.GetColumnName(tableIdentifier)));
        var childColumnNames = columnNames.Select(c => $@"{sqlTemplate.FormatTableOrColumnName("Child")}.{c}");

        migrationBuilder.Sql(sqlTemplate.CreateSqlTemplate
            .Replace("{tableName}", tableName)
            .Replace("{dataSourceName}", dataSourceName)
            .Replace("{columns}", string.Join(", ", columnNames))
            .Replace("{child.columns}", string.Join(", ", childColumnNames))
        );

        return migrationBuilder;
    }

    /// <summary>
    /// 删除树形实体查询视图
    /// </summary>
    /// <param name="migrationBuilder">迁移构造器</param>
    /// <param name="entityType">实体类型</param>
    /// <param name="sqlTemplate">Sql模板</param>
    /// <returns>迁移构造器</returns>
    public static MigrationBuilder DropTreeEntityQueryView(
        this MigrationBuilder migrationBuilder,
        IEntityType entityType,
        ITreeEntityDatabaseViewSqlTemplate sqlTemplate)
    {
        ArgumentNullException.ThrowIfNull(migrationBuilder);
        ArgumentNullException.ThrowIfNull(entityType);
        ArgumentNullException.ThrowIfNull(sqlTemplate);

        return migrationBuilder.DropTreeEntityQueryView(entityType.GetTableName()!, sqlTemplate);
    }

    /// <summary>
    /// 删除树形实体查询视图
    /// </summary>
    /// <param name="migrationBuilder">迁移构造器</param>
    /// <param name="tableName">视图对应的表名</param>
    /// <param name="sqlTemplate">Sql模板</param>
    /// <returns>迁移构造器</returns>
    public static MigrationBuilder DropTreeEntityQueryView(
        this MigrationBuilder migrationBuilder,
        string tableName,
        ITreeEntityDatabaseViewSqlTemplate sqlTemplate)
    {
        ArgumentNullException.ThrowIfNull(migrationBuilder);
        ArgumentNullException.ThrowIfNull(sqlTemplate);
        if (string.IsNullOrEmpty(tableName))
        {
            throw new ArgumentException($"“{nameof(tableName)}”不能为 null 或空。", nameof(tableName));
        }

        migrationBuilder.Sql(sqlTemplate.DropSqlTemplate.Replace("{tableName}", tableName));

        return migrationBuilder;
    }
}

public static class EntityMigrationBuilderExtensions
{
    /// <summary>
    /// 自动扫描迁移模型并配置实体查询视图
    /// </summary>
    /// <param name="migrationBuilder">迁移构造器</param>
    /// <param name="thisVersion">当前版本的迁移</param>
    /// <param name="previousVersion">上一个版本的迁移</param>
    /// <param name="isUp">是否为升级迁移</param>
    /// <param name="dependencyLogicallyDeletableEntityViewSqlTemplate">依赖项逻辑删除实体视图Sql模板</param>
    /// <param name="treeEntityViewSqlTemplate">树形实体视图Sql模板</param>
    /// <returns>迁移构造器</returns>
    public static MigrationBuilder ApplyEntityQueryView(
        this MigrationBuilder migrationBuilder,
        Migration thisVersion,
        Migration? previousVersion,
        bool isUp,
        IDependencyLogicallyDeletableEntityDatabaseViewSqlTemplate dependencyLogicallyDeletableEntityViewSqlTemplate,
        ITreeEntityDatabaseViewSqlTemplate treeEntityViewSqlTemplate)
    {
        ArgumentNullException.ThrowIfNull(migrationBuilder);
        ArgumentNullException.ThrowIfNull(thisVersion);
        ArgumentNullException.ThrowIfNull(dependencyLogicallyDeletableEntityViewSqlTemplate);
        ArgumentNullException.ThrowIfNull(treeEntityViewSqlTemplate);

        migrationBuilder.ApplyDependencyLogicallyDeletableEntityQueryView(
            thisVersion,
            previousVersion,
            isUp,
            dependencyLogicallyDeletableEntityViewSqlTemplate);

        migrationBuilder.ApplyTreeEntityQueryView(
            thisVersion,
            previousVersion,
            isUp,
            treeEntityViewSqlTemplate);

        return migrationBuilder;
    }

    /// <summary>
    /// 创建树形实体查询视图
    /// </summary>
    /// <param name="migrationBuilder">迁移构造器</param>
    /// <param name="entityType">实体类型</param>
    /// <param name="dependencyLogicallyDeletableEntityViewSqlTemplate">依赖项逻辑删除实体视图Sql模板</param>
    /// <param name="treeEntityViewSqlTemplate">树形实体视图Sql模板</param>
    /// <returns>迁移构造器</returns>
    public static MigrationBuilder CreateEntityQueryView(
        this MigrationBuilder migrationBuilder,
        IEntityType entityType,
        IDependencyLogicallyDeletableEntityDatabaseViewSqlTemplate dependencyLogicallyDeletableEntityViewSqlTemplate,
        ITreeEntityDatabaseViewSqlTemplate treeEntityViewSqlTemplate)
    {
        ArgumentNullException.ThrowIfNull(migrationBuilder);
        ArgumentNullException.ThrowIfNull(entityType);
        ArgumentNullException.ThrowIfNull(dependencyLogicallyDeletableEntityViewSqlTemplate);
        ArgumentNullException.ThrowIfNull(treeEntityViewSqlTemplate);

        migrationBuilder.CreateDependencyLogicallyDeletableEntityQueryView(entityType, dependencyLogicallyDeletableEntityViewSqlTemplate);
        migrationBuilder.CreateTreeEntityQueryView(entityType, treeEntityViewSqlTemplate);

        return migrationBuilder;
    }

    /// <summary>
    /// 删除实体查询视图
    /// </summary>
    /// <param name="migrationBuilder">迁移构造器</param>
    /// <param name="entityType">实体类型</param>
    /// <param name="dependencyLogicallyDeletableEntityViewSqlTemplate">依赖项逻辑删除实体视图Sql模板</param>
    /// <param name="treeEntityViewSqlTemplate">树形实体视图Sql模板</param>
    /// <returns>迁移构造器</returns>
    public static MigrationBuilder DropEntityQueryView(
        this MigrationBuilder migrationBuilder,
        IEntityType entityType,
        IDependencyLogicallyDeletableEntityDatabaseViewSqlTemplate dependencyLogicallyDeletableEntityViewSqlTemplate,
        ITreeEntityDatabaseViewSqlTemplate treeEntityViewSqlTemplate)
    {
        ArgumentNullException.ThrowIfNull(migrationBuilder);
        ArgumentNullException.ThrowIfNull(entityType);
        ArgumentNullException.ThrowIfNull(dependencyLogicallyDeletableEntityViewSqlTemplate);
        ArgumentNullException.ThrowIfNull(treeEntityViewSqlTemplate);

        return migrationBuilder.DropEntityQueryView(
            entityType.GetTableName()!,
            dependencyLogicallyDeletableEntityViewSqlTemplate,
            treeEntityViewSqlTemplate);
    }

    /// <summary>
    /// 删除实体查询视图
    /// </summary>
    /// <param name="migrationBuilder">迁移构造器</param>
    /// <param name="tableName">视图对应的表名</param>
    /// <param name="dependencyLogicallyDeletableEntityViewSqlTemplate">依赖项逻辑删除实体视图Sql模板</param>
    /// <param name="treeEntityViewSqlTemplate">树形实体视图Sql模板</param>
    /// <returns>迁移构造器</returns>
    public static MigrationBuilder DropEntityQueryView(
        this MigrationBuilder migrationBuilder,
        string tableName,
        IDependencyLogicallyDeletableEntityDatabaseViewSqlTemplate dependencyLogicallyDeletableEntityViewSqlTemplate,
        ITreeEntityDatabaseViewSqlTemplate treeEntityViewSqlTemplate)
    {
        ArgumentNullException.ThrowIfNull(migrationBuilder);
        ArgumentNullException.ThrowIfNull(dependencyLogicallyDeletableEntityViewSqlTemplate);
        ArgumentNullException.ThrowIfNull(treeEntityViewSqlTemplate);
        if (string.IsNullOrEmpty(tableName))
        {
            throw new ArgumentException($"“{nameof(tableName)}”不能为 null 或空。", nameof(tableName));
        }

        migrationBuilder.DropDependencyLogicallyDeletableEntityQueryView(tableName, dependencyLogicallyDeletableEntityViewSqlTemplate);
        migrationBuilder.DropTreeEntityQueryView(tableName, treeEntityViewSqlTemplate);

        return migrationBuilder;
    }
}

迁移脚本预览(节选)

CREATE VIEW QueryView_Tree_Entity2_1s    --创建视图
AS
WITH [temp]([Id], [DeletedAt], [DependencyDeletedAt], [Entity1_1_1Id], [Entity2Id], [Entity2_0Id], [IsLeaf], [IsRoot], [ParentId], [Text2_1], [Depth], [Path], [HasChildren]) AS
(
    --初始查询(这里的 [ParentId] IS NULL 在数据中是最底层的根节点)
    SELECT [Id], [DeletedAt], [DependencyDeletedAt], [Entity1_1_1Id], [Entity2Id], [Entity2_0Id], [IsLeaf], [IsRoot], [ParentId], [Text2_1],
        0 AS [Depth],
        '/' + CAST([Id] AS nvarchar(max)) + '/' AS [Path], --如果Id使用Guid类型,可能会导致层数太深时出问题(大概100层左右,超过4000字之后的字符串会被砍掉,sqlserver 2005以后用 nvarchar(max)可以突破限制),Guid的字数太多了
        (CASE WHEN EXISTS(SELECT 1 FROM [QueryView_Entity2_1s] WHERE [QueryView_Entity2_1s].[ParentId] = [Root].[Id]) THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END) AS [HasChildren]
    FROM [QueryView_Entity2_1s] AS [Root]
    WHERE [Root].[ParentId] IS NULL

    UNION ALL
    --递归条件
    SELECT [Child].[Id], [Child].[DeletedAt], [Child].[DependencyDeletedAt], [Child].[Entity1_1_1Id], [Child].[Entity2Id], [Child].[Entity2_0Id], [Child].[IsLeaf], [Child].[IsRoot], [Child].[ParentId], [Child].[Text2_1],
        [Parent].[Depth] + 1,
        [Parent].[Path] + CAST([Child].[Id] AS nvarchar(max)) + '/' AS [Path],
        (CASE WHEN EXISTS(SELECT 1 FROM [QueryView_Entity2_1s] WHERE [QueryView_Entity2_1s].[ParentId] = [Child].[Id]) THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END) AS [HasChildren]
    FROM [QueryView_Entity2_1s] AS [Child] --3:这里的临时表和原始数据表都必须使用别名不然递归的时候不知道查询的是哪个表的列
    INNER JOIN [temp] AS [Parent]
    ON ([Child].[ParentId] = [Parent].[Id]) --这个关联关系很重要,一定要理解一下谁是谁的父节点
)
--4:递归完成后 一定不要少了这句查询语句 否则会报错
SELECT *
FROM [temp];

MySql 8.0和Sqlite 3支持查询所需功能,其他数据库请自行验证。

Tips

开发测试时发现,如果用命令行工具会导致无法下断点单步调试迁移扩展,这一度让笔者很难受。经过一番折腾,发现可以使用以下代码在程序中调用迁移生成。

var modelInitializer = appDbContext.GetService<IModelRuntimeInitializer>();
var migrationsAssembly = appDbContext.GetService<IMigrationsAssembly>();
var modelDiffer = appDbContext.GetService<IMigrationsModelDiffer>();
var migrator = appDbContext.GetService<IMigrator>();

var firstModel = modelInitializer.Initialize(migrationsAssembly.CreateMigration(migrationsAssembly.Migrations.First().Value, appDbContext.Database.ProviderName!).TargetModel);
var snapshotModel = modelInitializer.Initialize(migrationsAssembly.ModelSnapshot!.Model);

var differences = modelDiffer.GetDifferences(
    migrationsAssembly.ModelSnapshot!.Model.GetRelationalModel(),
    firstModel.GetRelationalModel());

var script = migrator.GenerateScript(migrationsAssembly.Migrations.LastOrDefault().Key, "0"/*, migrationsAssembly.Migrations.FirstOrDefault().Key*/);

结语

经过3篇系列文,一个仅依赖EF Core,对业务代码0入侵,完全确保数据完整性的全自动审计、软删除和树形查询表就大功告成了!

本系列文的所需代码从构思到测试基本可用前后过了将近一个月,基本上可以说已经成为了项目这碟醋包了这个系列的一盘饺子了。包括之前的
基于EF Core存储的国际化服务

基于EF Core存储的Serilog持久化服务
其实也是项目的一部分。不过经过这一系列折腾,以后可以直接拿来用了,也不亏。

示例代码:
SoftDeleteDemo.rar
。主页显示异常请在libman.json上右键恢复前端包。

QQ群

读者交流QQ群:540719365
image

欢迎读者和广大朋友一起交流,如发现本书错误也欢迎通过博客园、QQ群等方式告知笔者。

本文地址:
https://www.cnblogs.com/coredx/p/18305284.html

bitwarden本地搭建(无需购买SSL证书)

在安装之前,笔者在这里先声明一下,我安装bitwarden使用的操作环境为ArchLinux,我的想法是,因为这只是一个“密码本”,并且最好能保证其能够在开机后占用尽量少的内存让密码本保持稳定运行。在此前提下,我选择了干净整洁的ArchLinux,关于其安装,大家可以看一下网上现有的教程,或者也可以看一下我的另一篇文章,
https://www.cnblogs.com/Thato/articles/18309473

Arch的安装不能说简单,但是也绝对说不上很难。关于完全安装完毕后的占用,我压到了350MB的运行内存占用,如下图,可以说是占用十分的少了

如果你不喜欢用或者不用Arch,也没有关系,本篇文章主要的分享目的是自签SSL证书使用https服务的流程,因为bitwarden强制要求运行在此环境下以保证密码安全,而自签既能剩下一笔资金,也能保证我们能够使用安全的bitwarden服务

那么接下来,是我们安装bitwarden要用到的软件

  • VMware虚拟机
  • Arch Linux操作系统
  • docker
  • docker compose
  • Nginx => 用于反向代理和加载ssl证书
  • 一个能用的代理 => 主要是拉docker镜像用,如果你有好的镜像源,其实这个就无所谓
  • 一个清醒的大脑
  • 一杯茶或者咖啡

docker与docker-compose的安装

安装docker

docker的安装我们可以直接使用

pacman -S docker

来安装,如下:

此时按下回车确认安装即可;当安装完毕后,我们可以再使用命令开启docker的守护进程并且设置开机自启

systemctl enable --now docker

当出现如上提示时,docker就启动完毕且添加开机自启了;检查一下docker服务是否正常,运行命令

docker version

当docker如上显示出Client和Server的信息后,说明docker安装设置完毕。

安装docker-compose

对于Arch来说,docker-compose可以直接使用pacman来安装,运行命令

pacman -S docker-compose

安装完成后执行命令

docker-compose version

当能够成功返回版本信息时,说明docker-compose也安装完毕了

bitwarden的安装

我这里使用了一个第三方的bitwarden的docker镜像,听别人说有解锁一些专业版的东西,这里就拿来用了。

加速docker下载

由于各种各样的原因,我们直接使用官方拉取镜像的时候多半情况不是很慢就是连接不上,为了解决这个问题,我这里给出一个可行的解决方案:调用物理机的代理程序

草图大致如上,我的天,好丑,哈哈哈哈。将就看一下,大致就是这个意思。

那么我们如何实现呢?

首先找到代理程序上关于“允许局域网连接”的选项,这里给出小猫和小V的示意图,大家可以任选一个软件去用,当然,有自己的用自己的也行,只要允许局域网连接即可。

随后记住端口号,这里我就用小猫了,记住端口号7890

之后打开VM,和控制面板,结合看一下NAT模式的网卡地址

可以看到我这里是192.168.131.1,那么结合刚才的端口号和网卡的地址。我们要访问的代理地址就是192.168.131.1:7890;访问192.168.131.1:7890即可映射到物理机的7890端口使用代理,这里各位根据自己的实际情况去更改即可。

拿到代理地址之后,配置docker,使其走代理,依次运行命令

mkdir -p /etc/systemd/system/docker.service.d
touch /etc/systemd/system/docker.service.d/http-proxy.conf
vim /etc/systemd/system/docker.service.d/http-proxy.conf

在vim编辑的文件中添加如下条目

[Service]
Environment="HTTP_PROXY=http://192.168.131.1:7890/"
Environment="HTTPS_PROXY=http:// 192.168.131.1:7890/"
Environment="NO_PROXY=localhost,127.0.0.1,.example.com"

保存后重启docker

systemctl daemon-reload
systemctl restart docker

此时docker加速就配置完成了,接下来我们来拉取镜像

拉取镜像&创建实例

运行命令

docker pull bitwardenrs/server:latest

拉取大佬用rust写好的bitwarden docker镜像

可以看到主机侧代理成功获取到了请求,并且docker已经开始使用主机侧代理拉取镜像了,此时我们等待镜像拉取完毕即可,镜像拉取过程的速度因网络状态而异。

当出现如上提示信息时,说明成功拉取完毕了镜像。随后我们来起一下容器,运行命令

docker run -d --name bitwarden -v /bw-data/:/data/ -p 8080:80 -e WEBSOCKET_ENABLED=true -p 3012:3012 -e DOMAIN=https://passwordserver.com bitwardenrs/server:latest

当出现如上提示时,我们的容器就启动完成了,使用命令

docker ps -a

看一下创建好的容器

如上,如果STATUS栏中如果显示Up xxxx seconds (health: starting)或者是Up xxx miniuts (health: starting)之类的时间信息,就没有问题。

//我之前使用CentOS 7安装,容器一启动STATUS状态就会秒Exit,有解决的同志们可以踹我一脚,我学习一下,感谢。

简单检查(此时bitwarden服务不可用,只是检查是否能够正常访问)

注意:此时bitwarden服务并不可用,只是检查是否能够正常访问

随后我们去访问一下web页面,看看是否有异常;访问之前需要知道我们的虚拟机ip地址。这里可以安装一个net-tools,运行命令

pacman -S net-tools #当然,你也可以使用ip a命令来查看ip地址

安装完成之后就可以使用ifconfig了,我们使用ifconfig来看一下ip地址

可以看到我的ip地址是192.168.131.151,并且启容器的时候我们是把docker的80端口映射到了Arch的8080端口上,所以这里我们要访问的地址就是

http://192.168.131.151:8080

尝试访问

可以发现我们的bitwarden服务已经搭出来了

但是,请注意!正如我本小节开头所说,此时服务并不可用,因为bitwarden要求强制使用https才能够进行操作,如下

很多同志可能就是卡在这一步了,上网搜索SSL证书的获取,大都是关于“先注册域名然后就可以免费申请一个SSL证书”之类的回答;但是注册域名也不是免费的,为了解决这个问题,我们可以自签一个SSL证书出来,随后就可以使用bitwarden的服务了。

关于自签SSL证书,你必须要知道的几点:

1. 完全免费,证书时长完全自定义

2. 完全能够保证bitwarden可用,即自签证书能够运行https服务

3. 自签证书不属于“受信证书”,如果你是公网服务,请老老实实注册域名使用受信证书

4. 浏览器会报一个警告,由于不是受信证书,但是我们服务可用就行了,这个不用管;所谓的“不安全”并不是你的密码不安全,web信息传输过程中是会加密的,如下图,我使用自己已经搭建好的另一台bitwarden密码服务器做示例

可以看到数据都是经过TLS加密的,而浏览器提示不安全仅仅是因为你的证书是自签而不是经过权威机构认证的证书,关于安全性这点,请放心。

生成自签证书

这里我们需要两个软件,一个是openssl,一个是jdk11,使用如下命令安装

pacman -S openssl


这里你可以休息一下,喝杯茶或者咖啡,起身运动一下,眺望一下远处,等到下载完毕后再继续安装操作
*
*
*
*
*
*
*
*
*
*
*
休息和安装完毕了吗?我们继续


安装完毕后,我们来继续操作(自签证书的生成参考了文章:
https://cloud.tencent.com/developer/article/1558378
)

首先来生成一个RSA私钥文件,使用命令:

openssl genrsa -des3 -out server.pass.key 2048

运行之后会要求输入一个密码,这里输入一下,会有一次密码输入和一次密码验证。

此密码后续操作中会多次用到,请记好,如果不慎遗忘,请从这步开始重新生成私钥

私钥生成完毕后,我们需要将其中的密码信息去除,让文件中不包含明文密码,执行命令

openssl rsa -in server.pass.key -out server.key

此过程中会让我们输入一次密码,就是刚才的密码,生成的无密码私钥为server.key

无密码私钥创建完毕之后,我们来生成一个CSR(证书签名请求),执行如下命令

openssl req -new -key server.key -out server.csr -subj "/C=CN/ST=Hello/L=Guys/O=Like/OU=AndSubscribe/CN=Me.Thank.you.com"

其中,/C=xxx表示的是国家,这里/C=CN即国家是CN;/ST表示省份;/L表示城市;/O表示组织或企业;/OU表示部门;/CN表示域名或IP。这些内容可以完全自定义,些什么都可以,注意不要使用特殊字符

可以看到生成了server.csr,随后我们继续操作,开始自签名操作,运行命令

openssl x509 -req -days 36500 -in server.csr -signkey server.key -out server.crt

其中比较重要的参数是days,这个是证书的有效时间,这里我们既然是自签证书,可以狠心一点,直接签个100年的出来。

可以看到成功输出了crt,自签完成。随后我们把这个证书放到ssl目录中,依次运行如下命令

mkdir -p /usr/local/ssl
cp server.key /usr/local/ssl/
cp server.crt /usr/local/ssl/

移动完成之后我们来继续操作,配置Nginx

使用Nginx反向代理配置https

现在我们有了证书了,该如何使用呢?难不成进到容器里面替换吗

其实完全不用,我们只要配置Nginx设置反向代理即可

首先来安装Nginx,使用命令

pacman -S nginx

安装完毕后运行如下命令

systemctl enable --now nginx

此处我们Nginx就配置完成了,随后我们来设置反向代理

修改配置文件/etc/nginx/nginx.conf,运行命令

vim /etc/nginx/nginx.conf

在其中修改如下内容(注意,一定要在规定的地方去改)

在http中添加:

	types_hash_max_size 4096;

将sever中的内容修改为(Server中error_page上面相关的参数项全部移除即可):

	listen 80;
	server_name passwordsever.com;
	# Allow large attachments
	client_max_body_size 128M;
	location / {
		proxy_pass http://localhost:8080;
		proxy_set_header Host \$host;
		proxy_set_header X-Real-IP \$remote_addr;
		proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
		proxy_set_header X-Forwarded-Proto \$scheme;
		}
	location /notifications/hub {
		proxy_pass http://localhost:3012;
		proxy_set_header Upgrade \$http_upgrade;
		proxy_set_header Connection "upgrade";
		}
	location /notifications/hub/negotiate {
		proxy_pass http://localhost:8080;
		}

	listen 443 ssl;
	ssl_certificate /usr/local/ssl/server.crt;
	ssl_certificate_key /usr/local/ssl/server.key;

配置完成之后保存文件,随后依次运行下面的命令

nginx -t
nginx -c /etc/nginx/nginx.conf
nginx -s reload

此时就配置完成了

正式访问bitwarden

我们来试试访问我们的bitwarden,此时我们已经配置好了https,此时访问
https://xxx.xxx.xxx.xxx
(你的服务器ip地址)即可

浏览器爆如上错误直接继续访问,此处的“不安全”原因上面已经强调过,不再赘述

可以看到此时成功进入网页了,那么我们开始使用吧。

创建一个新账户,点击创建账号

这里根据提示填写即可,主密码就是登录进bitwarden的密码,请一定牢记账号和密码

注册完毕即可登录

此时,你的bitwarden就搭建完毕了,关于bitwarden的使用,大家可以自行探索。

恭喜你走到了这一步!

背景

最近遇到个两年前遇到的问题,使用
virt-manager
提示
(virt-manager:873): Gtk-WARNING **: 14:53:28.147: cannot open display: :1
,当时专门运维的同事帮忙临时调了下
DISPLAY
变量,好像是将
:1
改成了
SSH用户本地IP:10.0
,当时的确好了,用完就关了再没用到,也没深究原因,那个运维同事也不大理解(网上查到的解决办法)。然而最近在做资产盘点,领导让我把我挂名管理的服务器作置换申请,需要知道虚拟机的信息,赶上盘到两年前有问题的机器上,又出现同样的问题,经过查找了资料找到了个几乎万无一失的理解,记一记。

DISPLAY变量是啥

首先,它是Linux X11 server(显示服务)用到的一个环境变量,用来指示你的显示(也可以包含键盘和鼠标)指向的显示服务地址,通常桌面PC该值会被设为
:0.0

其次,它的格式有三部分:
[主机名]
:
显示服务端口号-6000
.
显示器编号

  • [主机名]
    :一般是可以省略的,可以不写,也可以写成
    $HOSTNAME
    变量表示的主机名 或 localhost
  • 显示服务端口号-6000
    :意思是sshd服务的X11Forwarding占用端口减去6000的值
  • 显示器编号
    :一般都是0,表示第一个显示器

如何正确设置DISPLAY变量

分两种情况:

  • Linux桌面系统:直接设置
    :0.0
  • SSH连接的Linux服务器:需要按照格式进行检查。

检查步骤如下:

[root@hz ~]# netstat -anpt |grep sshd |grep LISTEN |grep 60
tcp   0  0 127.0.0.1:6010  0.0.0.0:*  LISTEN   30346/sshd: root@pt
tcp6  0  0 ::1:6010        :::*       LISTEN   30346/sshd: root@pt

找到60开头的sshd端口,这时是6010,减去6000是10,SSH只写第一显示器编号

则我的DISPLAY变量可设为
:10.0
或者
hz:10.0

如果上边的命令查不出来6000左右的端口号,请检查 /etc/ssh/sshd_config,确认
X11Forwarding yes
参数已配置并
systemctl restart sshd
,使用exit退出当前ssh,重新连接再尝试。

附:参考

The magic word in the X window system is DISPLAY. A display consists (simplified) of:

  • a keyboard,
  • a mouse
  • and a screen.

A display is managed by a server program, known as an X server. The server serves displaying capabilities to other programs that connect to it.

The remote server knows where it has to redirect the X network traffic via the definition of the DISPLAY environment variable which generally points to an X Display server located on your local computer.

The value of the display environment variable is:

hostname:D.S

where:

hostname is the name of the computer where the X server runs. An omitted hostname means the localhost.

D is a sequence number (usually 0). It can be varied if there are multiple displays connected to one computer.

S is the screen number. A display can actually have multiple screens. Usually, there's only one screen though where 0 is the default.

Example of values

localhost:4
google.com:0
:0.0

hostname:D.S
means screen S on display D of host hostname; the X server for this display is listening at TCP port 6000+D.

host/unix:D.S
means screen S on display D of host host; the X server for this display is listening at UNIX domain socket /tmp/.X11-unix/XD (so it's only reachable from host).

:D.S
is equivalent to host/unix:D.S, where host is the local hostname.

:0.0 means that we are talking about the first screen attached to your first display in your local host

Read more
here: support.objectplanet.com
and
here: superuser.com
and
here: docstore.mik.ua
.

From a X(7) man page:

From the user's perspective, every X server has a display name of the form:

hostname:displaynumber.screennumber

This information is used by the application to determine how it should connect to the server and which screen it should use by default (on displays with multiple monitors):

hostname The hostname specifies the name of the machine to which the display is physically connected. If the hostname is not given, the most efficient way of communicating to a server on the same machine will be used. displaynumber The phrase "display" is usually used to refer to a collection of monitors that share a common keyboard and pointer (mouse, tablet, etc.). Most workstations tend to only have one keyboard, and therefore, only one display. Larger, multi-user systems, however, frequently have several displays so that more than one person can be doing graphics work at once. To avoid confusion, each display on a machine is assigned a display number (beginning at 0) when the X server for that display is started. The display number must always be given in a display name. screennumber Some displays share a single keyboard and pointer among two or more monitors. Since each monitor has its own set of windows, each screen is assigned a screen number (beginning at 0) when the X server for that display is started. If the screen number is not given, screen 0 will be used.