論如何直接用EF Core實現創建更新時間、用戶審計,自動化樂觀併發、軟刪除和樹形查詢(上)

来源:https://www.cnblogs.com/coredx/p/18305165
-Advertisement-
Play Games

前言 資料庫併發,數據審計和軟刪除一直是數據持久化方面的經典問題。早些時候,這些工作需要手寫複雜的SQL或者通過存儲過程和觸發器實現。手寫複雜SQL對軟體可維護性構成了相當大的挑戰,隨著SQL字數的變多,用到的嵌套和複雜語法增加,可讀性和可維護性的難度是幾何級暴漲。因此如何在實現功能的同時控制這些S ...


前言

資料庫併發,數據審計和軟刪除一直是數據持久化方面的經典問題。早些時候,這些工作需要手寫複雜的SQL或者通過存儲過程和觸發器實現。手寫複雜SQL對軟體可維護性構成了相當大的挑戰,隨著SQL字數的變多,用到的嵌套和複雜語法增加,可讀性和可維護性的難度是幾何級暴漲。因此如何在實現功能的同時控制這些SQL的複雜度是一個很有價值的問題。而且這個問題同時涉及應用軟體和資料庫兩個相對獨立的體系,平行共管也是產生混亂的一大因素。

EF Core作為 .NET平臺的高級ORM框架,可以托管和資料庫的交互,同時提供了大量擴展點方便自定義。以此為基點把對資料庫的操作托管後便可以解決平行共管所產生的混亂,利用LINQ則可以最大程度上降低軟體代碼的維護難度。

由於項目需要,筆者先後開發併發布了通用的基於EF Core存儲的國際化服務基於EF Core存儲的Serilog持久化服務,不過這兩個功能包並沒有深度利用EF Core,雖然主要是因為沒什麼必要。但是項目還需要提供常用的數據審計和軟刪除功能,因此對EF Core進行了一些更深入的研究。

起初有考慮過是否使用現成的ABP框架來處理這些功能,但是在其他項目的使用體驗來說並不算好,其中充斥著大量上下文依賴的功能,而且這些依賴信息能輕易藏到和最終業務代碼相距十萬八千里的地方(特別是代碼還是別人寫的時候),然後在不經意間給你一個大驚喜。對於以代碼正交性、非誤導性,純函數化為追求的一介碼農(看過我發佈的那兩個功能包的朋友應該有感覺,一個功能筆者也要根據用途劃分為不同的包,確保解決方案中的各個項目都能按需引用,不會殘留無用的代碼),實在是喜歡不起來ABP這種全家桶。

鑒於項目規模不大,筆者決定針對這些需求做一個專用功能,目標是儘可能減少依賴,方便將來複用到其他項目,降低和其他功能功能衝突的風險。現在筆者將用一系列博客做成果展示。由於這些功能沒有經過大範圍測試,不確定是否存在未知缺陷,因此暫不打包發佈。

新書宣傳

有關新書的更多介紹歡迎查看《C#與.NET6 開發從入門到實踐》上市,作者親自來打廣告了!
image

正文

由於這些功能設計的代碼量和知識點較多,為控制篇幅,本文介紹數據審計和樂觀併發功能。

EF Core 3.0新增了偵聽器功能,允許在實際執行操作之前或之後插入自定義操作,利用這個功能可以實現數據審計的自動化。為此需要做些前期準備。

審計實體介面

樂觀併發介面

/// <summary>
/// 樂觀併發介面
/// </summary>
public interface IOptimisticConcurrencySupported
{
    /// <summary>
    /// 行版本,樂觀併發鎖
    /// </summary>
    [ConcurrencyCheck]
    string? ConcurrencyStamp { get; set; }
}

SqlServer資料庫支持自動的行版本功能,但是大多數其他資料庫並不支持,因此選用相容性更好的方案。Identity Core為了相容性也不用行版本實現樂觀併發。

時間審計介面

/// <summary>
/// 創建和最近更新時間審計的合成介面
/// </summary>
public interface IFullyTimeAuditable : ICreationTimeAuditable, ILastUpdateTimeAuditable;

/// <summary>
/// 創建時間審計介面
/// </summary>
public interface ICreationTimeAuditable
{
    /// <summary>
    /// 創建時間標記
    /// </summary>
    DateTimeOffset? CreatedAt { get; set; }
}

/// <summary>
/// 最近更新時間審計介面
/// </summary>
public interface ILastUpdateTimeAuditable
{
    /// <summary>
    /// 最近更新時間標記
    /// </summary>
    DateTimeOffset? LastUpdatedAt { get; set; }
}

操作人審計介面

/// <summary>
/// 創建和最近更新用戶審計的合成介面
/// </summary>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
public interface IFullyOperatorAuditable<TIdentityKey>
    : ICreationUserAuditable<TIdentityKey>
    , ILastUpdateUserAuditable<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>;

/// <summary>
/// 包括導航的創建和最近更新用戶審計的合成介面
/// </summary>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
/// <typeparam name="TUser">用戶類型</typeparam>
public interface IFullyOperatorAuditable<TIdentityKey, TUser>
    : ICreationUserAuditable<TIdentityKey, TUser>
    , ILastUpdateUserAuditable<TIdentityKey, TUser>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
    where TUser : class;

/// <summary>
/// 創建用戶審計介面
/// </summary>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
public interface ICreationUserAuditable<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
{
    /// <summary>
    /// 創建用戶Id
    /// </summary>
    TIdentityKey? CreatedById { get; set; }
}

/// <summary>
/// 包括導航的創建用戶審計介面
/// </summary>
/// <typeparam name="TUser">用戶類型</typeparam>
/// <inheritdoc />
public interface ICreationUserAuditable<TIdentityKey, TUser> : ICreationUserAuditable<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
    where TUser : class
{
    /// <summary>
    /// 創建用戶
    /// </summary>
    TUser? CreatedBy { get; set; }
}

/// <summary>
/// 最近更新用戶審計介面
/// </summary>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
public interface ILastUpdateUserAuditable<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
{
    /// <summary>
    /// 最近更新用戶Id
    /// </summary>
    TIdentityKey? LastUpdatedById { get; set; }
}

/// <summary>
/// 包括導航的最近更新用戶審計介面
/// </summary>
/// <typeparam name="TUser">用戶類型</typeparam>
/// <inheritdoc />
public interface ILastUpdateUserAuditable<TIdentityKey, TUser> : ILastUpdateUserAuditable<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
    where TUser : class
{
    /// <summary>
    /// 最近更新用戶
    /// </summary>
    TUser? LastUpdatedBy { get; set; }
}

使用介面方便和已有代碼集成。帶導航的操作人介面使用結構體Id方便準確控制外鍵可空性。

需要的輔助方法

public static class RuntimeTypeExtensions
{
    /// <summary>
    /// 判斷 <paramref name="type"/> 指定的類型是否派生自 <typeparamref name="T"/> 類型,或實現了 <typeparamref name="T"/> 介面
    /// </summary>
    /// <typeparam name="T">要匹配的類型</typeparam>
    /// <param name="type">需要測試的類型</param>
    /// <returns>如果 <paramref name="type"/> 指定的類型派生自 <typeparamref name="T"/> 類型,或實現了 <typeparamref name="T"/> 介面,則返回 <see langword="true"/>,否則返回 <see langword="false"/>。</returns>
    public static bool IsDerivedFrom<T>(this Type type)
    {
        return IsDerivedFrom(type, typeof(T));
    }

    /// <summary>
    /// 判斷 <paramref name="type"/> 指定的類型是否繼承自 <paramref name="pattern"/> 指定的類型,或實現了 <paramref name="pattern"/> 指定的介面
    /// <para>支持開放式泛型,如<see cref="List{T}" /></para>
    /// </summary>
    /// <param name="type">需要測試的類型</param>
    /// <param name="pattern">要匹配的類型,如 <c>typeof(int)</c>,<c>typeof(IEnumerable)</c>,<c>typeof(List&lt;&gt;)</c>,<c>typeof(List&lt;int&gt;)</c>,<c>typeof(IDictionary&lt;,&gt;)</c></param>
    /// <returns>如果 <paramref name="type"/> 指定的類型繼承自 <paramref name="pattern"/> 指定的類型,或實現了 <paramref name="pattern"/> 指定的介面,則返回 <see langword="true"/>,否則返回 <see langword="false"/>。</returns>
    public static bool IsDerivedFrom(this Type type, Type pattern)
    {
        ArgumentNullException.ThrowIfNull(type);
        ArgumentNullException.ThrowIfNull(pattern);

        // 測試非泛型類型(如ArrayList)或確定類型參數的泛型類型(如List<int>,類型參數T已經確定為 int)
        if (type.IsSubclassOf(pattern)) return true;

        // 測試非泛型介面(如IEnumerable)或確定類型參數的泛型介面(如IEnumerable<int>,類型參數T已經確定為 int)
        if (pattern.IsAssignableFrom(type)) return true;

        // 測試泛型介面(如IEnumerable<>,IDictionary<,>,未知類型參數,留空)
        var isTheRawGenericType = type.GetInterfaces().Any(IsTheRawGenericType);
        if (isTheRawGenericType) return true;

        // 測試泛型類型(如List<>,Dictionary<,>,未知類型參數,留空)
        while (type != null && type != typeof(object))
        {
            isTheRawGenericType = IsTheRawGenericType(type);
            if (isTheRawGenericType) return true;
            type = type.BaseType!;
        }

        // 沒有找到任何匹配的介面或類型。
        return false;

        // 測試某個類型是否是指定的原始介面。
        bool IsTheRawGenericType(Type test)
            => pattern == (test.IsGenericType ? test.GetGenericTypeDefinition() : test);
    }
}

/// <summary>
/// 實體配置相關泛型方法生成擴展
/// </summary>
internal static class EntityConfigurationMethodsHelper
{
    private const BindingFlags _bindingFlags = BindingFlags.Public | BindingFlags.Static;
    private static readonly ImmutableArray<MethodInfo> _configurationMethods;
    private static readonly MethodInfo _genericEntityTypeBuilderGetterMethod;

    static EntityConfigurationMethodsHelper()
    {
        _configurationMethods =
            [
                .. typeof(EntityModelBuilderExtensions).GetMethods(_bindingFlags),
                .. typeof(OperationUserAuditableEntityModelBuilderExtensions).GetMethods(_bindingFlags),
                .. typeof(TimeAuditableEntityModelBuilderExtensions).GetMethods(_bindingFlags),
                .. typeof(TreeEntityModelBuilderExtensions).GetMethods(_bindingFlags),
            ];

        _genericEntityTypeBuilderGetterMethod = typeof(ModelBuilder)
            .GetMethods(BindingFlags.Public | BindingFlags.Instance)
            .Where(static m => m.Name is nameof(ModelBuilder.Entity))
            .Where(static m => m.IsGenericMethod)
            .Where(static m => m.GetParameters().Length is 0)
            .Single();
    }

    /// <summary>
    /// 獲取泛型實體類型配置擴展方法
    /// </summary>
    /// <param name="name">方法名</param>
    /// <param name="ParametersCount">參數數量</param>
    /// <returns>已生成的封閉式泛型配置擴展方法</returns>
    internal static MethodInfo GetEntityTypeConfigurationMethod(string name, int ParametersCount, params Type[] typeParameterTypes)
    {
        ArgumentNullException.ThrowIfNull(name);
        ArgumentNullException.ThrowIfNull(typeParameterTypes);

        return _configurationMethods
            .Where(m => m.Name == name)
            .Where(m => m.GetParameters().Length == ParametersCount)
            .Where(static m => m.IsGenericMethod)
            .Where(m => m.GetGenericArguments().Length == typeParameterTypes.Length)
            .Single()
            .MakeGenericMethod(typeParameterTypes);

    }

    /// <summary>
    /// 獲取泛型實體類型構造器
    /// </summary>
    /// <param name="entity">實體類型</param>
    /// <returns></returns>
    internal static MethodInfo GetEntityTypeBuilderMethod(IMutableEntityType entity)
    {
        ArgumentNullException.ThrowIfNull(entity);

        // 動態生成泛型方法使配置邏輯擁有唯一的定義位置,避免發生不必要的問題
        return _genericEntityTypeBuilderGetterMethod.MakeGenericMethod(entity.ClrType);
    }
}

/// <summary>
/// 指示實體配置適用於何種資料庫提供程式
/// </summary>
/// <param name="ProviderName"></param>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class DatabaseProviderAttribute(string ProviderName) : Attribute
{
    /// <summary>
    /// 提供程式名稱
    /// </summary>
    public string ProviderName { get; } = ProviderName;
}

把實體配置擴展方法緩存起來方便之後批量調用,因為EF Core的泛型和非泛型實體構造器無法直接轉換,只能通過反射動態生成泛型方法復用單體配置擴展。這樣能保證配置代碼只有唯一一份,避免重覆代碼導致維護時出現疏漏。

實體模型配置擴展

樂觀併發擴展

/// <summary>
/// 配置樂觀併發實體的併發檢查欄位
/// </summary>
/// <typeparam name="TEntity">實體類型</typeparam>
/// <param name="builder">實體類型構造器</param>
/// <returns>實體屬性構造器</returns>
public static PropertyBuilder<string> ConfigureForIOptimisticConcurrencySupported<TEntity>(
    this EntityTypeBuilder<TEntity> builder)
    where TEntity : class, IOptimisticConcurrencySupported
{
    ArgumentNullException.ThrowIfNull(builder);

    return builder.Property(e => e.ConcurrencyStamp!).IsConcurrencyToken();
}

/// <summary>
/// 批量配置樂觀併發實體的併發檢查欄位
/// </summary>
/// <param name="modelBuilder">模型構造器</param>
/// <returns>模型構造器</returns>
public static ModelBuilder ConfigureForIOptimisticConcurrencySupported(this ModelBuilder modelBuilder)
{
    ArgumentNullException.ThrowIfNull(modelBuilder);

    foreach (var entity
        in modelBuilder.Model.GetEntityTypes()
            .Where(static e => !e.HasSharedClrType)
            .Where(static e => e.ClrType.IsDerivedFrom<IOptimisticConcurrencySupported>()))
    {
        var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
        var optimisticConcurrencySupportedMethod = GetEntityTypeConfigurationMethod(
            nameof(ConfigureForIOptimisticConcurrencySupported),
            1,
            entity.ClrType);

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

    return modelBuilder;
}

時間審計擴展

/// <summary>
/// 實體時間審計配置擴展
/// </summary>
public static class TimeAuditableEntityModelBuilderExtensions
{
    /// <summary>
    /// 配置創建時間審計
    /// </summary>
    /// <typeparam name="TEntity">實體類型</typeparam>
    /// <param name="builder">實體類型構造器</param>
    /// <param name="defaultValueSql">預設值Sql</param>
    /// <returns>實體類型構造器</returns>
    public static EntityTypeBuilder<TEntity> ConfigureForCreationTimeAuditable<TEntity>(
        this EntityTypeBuilder<TEntity> builder,
        ITimeAuditableDefaultValueSql defaultValueSql)
        where TEntity : class, ICreationTimeAuditable
    {
        builder.Property(e => e.CreatedAt)
            .IsRequired()
            .HasDefaultValueSql(defaultValueSql.Sql);

        return builder;
    }

    /// <summary>
    /// 批量配置創建時間審計
    /// </summary>
    /// <param name="modelBuilder">模型構造器</param>
    /// <returns>模型構造器</returns>
    public static ModelBuilder ConfigureForCreationTimeAuditable(
        this ModelBuilder modelBuilder,
        ITimeAuditableDefaultValueSql defaultValueSql)
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ICreationTimeAuditable>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
            var creationTimeAuditableMethod = GetEntityTypeConfigurationMethod(
                nameof(ConfigureForCreationTimeAuditable),
                2,
                entity.ClrType);

            creationTimeAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null), defaultValueSql]);
        }

        return modelBuilder;
    }

    /// <summary>
    /// 配置最近更新時間審計
    /// </summary>
    /// <typeparam name="TEntity">實體類型</typeparam>
    /// <param name="builder">實體類型構造器</param>
    /// <param name="defaultValueSql">預設值Sql</param>
    /// <returns>實體類型構造器</returns>
    public static EntityTypeBuilder<TEntity> ConfigureForLastUpdateTimeAuditable<TEntity>(
        this EntityTypeBuilder<TEntity> builder,
        ITimeAuditableDefaultValueSql defaultValueSql)
        where TEntity : class, ILastUpdateTimeAuditable
    {
        builder.Property(e => e.LastUpdatedAt)
            .IsRequired()
            .HasDefaultValueSql(defaultValueSql.Sql);

        return builder;
    }

    /// <summary>
    /// 批量配置最近更新時間審計
    /// </summary>
    /// <param name="modelBuilder">模型構造器</param>
    /// <returns>模型構造器</returns>
    public static ModelBuilder ConfigureForLastUpdateTimeAuditable(
        this ModelBuilder modelBuilder,
        ITimeAuditableDefaultValueSql defaultValueSql)
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ILastUpdateTimeAuditable>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
            var lastUpdateTimeAuditableMethod = GetEntityTypeConfigurationMethod(
                nameof(ConfigureForLastUpdateTimeAuditable),
                2,
                entity.ClrType);

            lastUpdateTimeAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null), defaultValueSql]);
        }

        return modelBuilder;
    }

    /// <summary>
    /// 配置完整時間審計
    /// </summary>
    /// <typeparam name="TEntity">實體類型</typeparam>
    /// <param name="builder">實體類型構造器</param>
    /// <param name="defaultValueSql">預設值Sql</param>
    /// <returns>實體類型構造器</returns>
    public static EntityTypeBuilder<TEntity> ConfigureForFullyTimeAuditable<TEntity>(
        this EntityTypeBuilder<TEntity> builder,
        ITimeAuditableDefaultValueSql defaultValueSql)
        where TEntity : class, IFullyTimeAuditable
    {
        builder
            .ConfigureForCreationTimeAuditable(defaultValueSql)
            .ConfigureForLastUpdateTimeAuditable(defaultValueSql);

        return builder;
    }

    /// <summary>
    /// 批量配置時間審計
    /// </summary>
    /// <param name="modelBuilder">模型構造器</param>
    /// <returns>模型構造器</returns>
    public static ModelBuilder ConfigureForTimeAuditable(
        this ModelBuilder modelBuilder,
        ITimeAuditableDefaultValueSql defaultValueSql)
    {
        modelBuilder
            .ConfigureForCreationTimeAuditable(defaultValueSql)
            .ConfigureForLastUpdateTimeAuditable(defaultValueSql);

        return modelBuilder;
    }
}

時間審計使用預設值SQL儘可能使資料庫和代碼統一邏輯,即使直接向資料庫插入記錄也能儘量保證有相關審計數據。只是最近更新時間在更新時實在是做不到資料庫級別的自動,用觸發器會阻止手動操作數據,所以不用。

時間列的預設值SQL在不同資料庫下有差異,因此需要從外部傳入,方便根據資料庫類型切換。

/// <summary>
/// 實體時間審計預設值Sql
/// </summary>
public interface ITimeAuditableDefaultValueSql
{
    string Sql { get; }
}

public class DefaultSqlServerTimeAuditableDefaultValueSql : ITimeAuditableDefaultValueSql
{
    public static DefaultSqlServerTimeAuditableDefaultValueSql Instance => new();

    public string Sql => "GETDATE()";

    private DefaultSqlServerTimeAuditableDefaultValueSql() { }
}

public class DefaultMySqlTimeAuditableDefaultValueSql : ITimeAuditableDefaultValueSql
{
    public static DefaultMySqlTimeAuditableDefaultValueSql Instance => new();

    public string Sql => "CURRENT_TIMESTAMP(6)";

    private DefaultMySqlTimeAuditableDefaultValueSql() { }
}

操作人審計擴展

/// <summary>
/// 實體操作人審計配置擴展
/// </summary>
public static class OperationUserAuditableEntityModelBuilderExtensions
{
    /// <summary>
    /// 配置實體創建人外鍵和導航屬性
    /// </summary>
    /// <typeparam name="TEntity">實體類型</typeparam>
    /// <typeparam name="TUser">用戶實體類型</typeparam>
    /// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
    /// <param name="builder">實體類型構造器</param>
    /// <returns>實體類型構造器</returns>
    public static EntityTypeBuilder<TEntity> ConfigureForNavigationIncludedCreationUserAuditable<TEntity, TUser, TIdentityKey>(
        this EntityTypeBuilder<TEntity> builder)
        where TEntity : class, ICreationUserAuditable<TIdentityKey, TUser>
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        builder
            .HasOne(b => b.CreatedBy)
            .WithMany()
            .HasForeignKey(b => b.CreatedById);

        return builder;
    }

    /// <summary>
    /// 批量配置實體創建人外鍵和導航屬性
    /// </summary>
    /// <typeparam name="TUser">用戶實體類型</typeparam>
    /// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
    /// <param name="modelBuilder">實體構造器</param>
    /// <returns>當前實體構造器</returns>
    public static ModelBuilder ConfigureForNavigationIncludedCreationUserAuditable<TUser, TIdentityKey>(
        this ModelBuilder modelBuilder)
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ICreationUserAuditable<TIdentityKey, TUser>>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
            var navigationIncludedCreationUserAuditableMethod = GetEntityTypeConfigurationMethod(
                nameof(ConfigureForNavigationIncludedCreationUserAuditable),
                1,
                [entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);

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

        return modelBuilder;
    }

    /// <summary>
    /// 批量配置實體創建人外鍵,如果有導航屬性就同時配置導航屬性
    /// </summary>
    /// <typeparam name="TUser">用戶實體類型</typeparam>
    /// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
    /// <param name="modelBuilder">實體構造器</param>
    /// <returns>當前實體構造器</returns>
    public static ModelBuilder ConfigureForCreationUserOrNavigationIncludedAuditable<TUser, TIdentityKey>(
        this ModelBuilder modelBuilder)
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ICreationUserAuditable<TIdentityKey>>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);

            MethodInfo creationUserAuditableMethod;
            if (entity.ClrType.IsDerivedFrom<ICreationUserAuditable<TIdentityKey, TUser>>())
            {
                creationUserAuditableMethod = GetEntityTypeConfigurationMethod(
                    nameof(ConfigureForNavigationIncludedCreationUserAuditable),
                    1,
                    [entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);

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

        return modelBuilder;
    }

    /// <summary>
    /// 配置實體最近修改人外鍵和導航屬性
    /// </summary>
    /// <typeparam name="TEntity">實體類型</typeparam>
    /// <typeparam name="TUser">用戶實體類型</typeparam>
    /// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
    /// <param name="builder">實體類型構造器</param>
    /// <returns>實體類型構造器</returns>
    public static EntityTypeBuilder<TEntity> ConfigureForNavigationIncludedLastUpdateUserAuditable<TEntity, TUser, TIdentityKey>(
        this EntityTypeBuilder<TEntity> builder)
        where TEntity : class, ILastUpdateUserAuditable<TIdentityKey, TUser>
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        builder
            .HasOne(b => b.LastUpdatedBy)
            .WithMany()
            .HasForeignKey(b => b.LastUpdatedById);

        return builder;
    }

    /// <summary>
    /// 批量配置實體最近修改人外鍵和導航屬性
    /// </summary>
    /// <typeparam name="TUser">用戶實體類型</typeparam>
    /// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
    /// <param name="modelBuilder">實體構造器</param>
    /// <returns>當前實體構造器</returns>
    public static ModelBuilder ConfigureForNavigationIncludedLastUpdateUserAuditable<TUser, TIdentityKey>(
        this ModelBuilder modelBuilder)
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ILastUpdateUserAuditable<TIdentityKey, TUser>>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
            var navigationIncludedLastUpdateUserAuditableMethod = GetEntityTypeConfigurationMethod(
                nameof(ConfigureForNavigationIncludedLastUpdateUserAuditable),
                1,
                [entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);

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

        return modelBuilder;
    }

    /// <summary>
    /// 批量配置實體最近修改人外鍵,如果有導航屬性就同時配置導航屬性
    /// </summary>
    /// <typeparam name="TUser">用戶實體類型</typeparam>
    /// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
    /// <param name="modelBuilder">實體構造器</param>
    /// <returns>當前實體構造器</returns>
    public static ModelBuilder ConfigureForLastUpdateUserOrNavigationIncludedAuditable<TUser, TIdentityKey>(
        this ModelBuilder modelBuilder)
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ILastUpdateUserAuditable<TIdentityKey>>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);

            MethodInfo lastUpdateUserAuditableMethod;
            if (entity.ClrType.IsDerivedFrom<ILastUpdateUserAuditable<TIdentityKey, TUser>>())
            {
                lastUpdateUserAuditableMethod = GetEntityTypeConfigurationMethod(
                    nameof(ConfigureForNavigationIncludedLastUpdateUserAuditable),
                    1,
                    [entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);

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

        return modelBuilder;
    }
}

沒有導航屬性的介面是為用戶表在其他資料庫的情況預留的,因此這個版本的介面不做作任何特殊配置。

資料庫上下文

// 其中IdentityKey是int的全局類型別名,上下文類型繼承自Identity Core上下文,用於演示操作用戶自動審計
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
    : ApplicationIdentityDbContext<
        ApplicationUser,
        ApplicationRole,
        IdentityKey,
        ApplicationUserClaim,
        ApplicationUserRole,
        ApplicationUserLogin,
        ApplicationRoleClaim,
        ApplicationUserToken>(options)
{
    // 其他無關代碼

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // 其他無關代碼

        // 自動根據資料庫類型進行資料庫相關的模型配置
        switch (Database.ProviderName)
        {
            case _msSqlServerProvider:
                modelBuilder.ApplyConfigurationsFromAssembly(
                    typeof(LogRecordEntityTypeConfiguration).Assembly,
                    type => type.GetCustomAttributes<DatabaseProviderAttribute>().Any(a => a.ProviderName is _msSqlServerProvider));

                modelBuilder.ConfigureForTimeAuditable(DefaultSqlServerTimeAuditableDefaultValueSql.Instance);
                break;
            case _pomeloMySqlProvider:
                modelBuilder.ApplyConfigurationsFromAssembly(
                    typeof(LogRecordEntityTypeConfiguration).Assembly,
                    type => type.GetCustomAttributes<DatabaseProviderAttribute>().Any(a => a.ProviderName is _pomeloMySqlProvider));

                modelBuilder.ConfigureForTimeAuditable(DefaultMySqlTimeAuditableDefaultValueSql.Instance);
                break;
            case _msSqliteProvider:
                goto default;
            default:
                throw new NotSupportedException(Database.ProviderName);
        }

        // 配置其他資料庫中立的模型配置
        modelBuilder.ConfigureForIOptimisticConcurrencySupported();

        modelBuilder.ConfigureForCreationUserOrNavigationIncludedAuditable<ApplicationUser, IdentityKey>();
        modelBuilder.ConfigureForLastUpdateUserOrNavigationIncludedAuditable<ApplicationUser, IdentityKey>();
    }
}

項目使用MySQL,而VS會附帶一個SqlServer單機版,所以暫時使用這兩個資料庫進行演示,如果需要支持其他資料庫,可自行改造。

EF Core偵聽器

併發檢查偵聽器

/// <summary>
/// 為併發檢查標記設置值,如果有邏輯刪除實體,應該位於邏輯刪除攔截器之後
/// </summary>
public class OptimisticConcurrencySupportedSaveChangesInterceptor : SaveChangesInterceptor
{
    protected IServiceScopeFactory ScopeFactory { get; }

    public OptimisticConcurrencySupportedSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
    {
        ArgumentNullException.ThrowIfNull(scopeFactory);

        ScopeFactory = scopeFactory;
    }

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        OnSavingChanges(eventData);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        OnSavingChanges(eventData);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    /// <summary>
    /// 處理實體的併發檢查令牌,並忽略由<see cref="ShouldProcessEntry"/>排除的實體
    /// </summary>
    /// <param name="eventData"></param>
    protected virtual void OnSavingChanges(DbContextEventData eventData)
    {
        ArgumentNullException.ThrowIfNull(eventData.Context);

        eventData.Context.ChangeTracker.DetectChanges();

        var entries = eventData.Context.ChangeTracker.Entries()
            .Where(static e => e.State is EntityState.Added or EntityState.Modified)
            .Where(ShouldProcessEntry);

        foreach (var entry in entries)
        {
            if (entry.Entity is IOptimisticConcurrencySupported optimistic)
            {
                if (entry.State is EntityState.Added)
                {
                    optimistic.ConcurrencyStamp = Guid.NewGuid().ToString();
                }
                if (entry.State is EntityState.Modified)
                {
                    // 如果是更新實體,需要分別處理原值和新值
                    var concurrencyStamp = entry.Property(nameof(IOptimisticConcurrencySupported.ConcurrencyStamp));
                    // 實體的當前值要指定為原值
                    concurrencyStamp!.OriginalValue = (entry.Entity as IOptimisticConcurrencySupported)!.ConcurrencyStamp;
                    // 然後重新生成新值
                    concurrencyStamp.CurrentValue = Guid.NewGuid().ToString();
                }
            }
        }
    }

    /// <summary>
    /// 用於排除在其他位置處理過併發檢查令牌的實體
    /// </summary>
    /// <param name="entry">實體</param>
    /// <returns>如果應該由當前攔截器處理返回<see langword="true"/>,否則返回<see langword="false"/>。</returns>
    protected virtual bool ShouldProcessEntry(EntityEntry entry) => true;
}

/// <summary><inheritdoc cref="OptimisticConcurrencySupportedSaveChangesInterceptor"/></summary>
/// <remarks>忽略用戶實體的併發檢查令牌,Identity服務已經處理過實體</remarks>
public class IdentityOptimisticConcurrencySupportedSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
    : OptimisticConcurrencySupportedSaveChangesInterceptor(scopeFactory)
{
    /// <summary>
    /// 忽略Identity內置併發檢查的實體
    /// </summary>
    /// <param name="entry">待檢查的實體</param>
    /// <returns>不是IdentityUser的實體</returns>
    protected override bool ShouldProcessEntry(EntityEntry entry)
    {
        var type = entry.Entity.GetType();
        var isUserOrRole = type.IsDerivedFrom(typeof(IdentityUser<>)) || type.IsDerivedFrom(typeof(IdentityRole<>));
        return !isUserOrRole;
    }
}

Identity Core有一套內置的併發檢查處理機制,因此需要對Identity相關實體進行排除,防止重覆處理引起異常。

時間審計偵聽器

/// <summary>
/// 為操作時間審計設置值,如果已經手動設置有效值,不會再次設置。如果有邏輯刪除實體,應該位於邏輯刪除攔截器之前。<br/>
/// 刪除時間已經由邏輯刪除標記保留,不應該用刪除時間覆蓋更新時間,在邏輯刪除之前使用避免誤操作由邏輯刪除攔截器設置的已編輯的實體。
/// </summary>
public class OperationTimeAuditableSaveChangesInterceptor : SaveChangesInterceptor
{
    protected IServiceScopeFactory ScopeFactory { get; }

    /// <summary>
    /// 為操作時間審計設置值,如果已經手動設置有效值,不會再次設置。如果有邏輯刪除實體,應該位於邏輯刪除攔截器之前。<br/>
    /// 刪除時間已經由邏輯刪除標記保留,不應該用刪除時間覆蓋更新時間,在邏輯刪除之前使用避免誤操作由邏輯刪除攔截器設置的已編輯的實體。
    /// </summary>
    /// <param name="scopeFactory"></param>
    public OperationTimeAuditableSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
    {
        ArgumentNullException.ThrowIfNull(scopeFactory);

        ScopeFactory = scopeFactory;
    }

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        OnSavingChanges(eventData);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        OnSavingChanges(eventData);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    /// <summary>
    /// 處理實體的審計時間
    /// </summary>
    /// <param name="eventData"></param>
    protected virtual void OnSavingChanges(DbContextEventData eventData)
    {
        ArgumentNullException.ThrowIfNull(eventData.Context);

        using var scope = ScopeFactory.CreateScope();
        var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();

        eventData.Context.ChangeTracker.DetectChanges();

        var entries = eventData.Context.ChangeTracker.Entries()
            .Where(static e => e.State is EntityState.Added or EntityState.Modified);

        foreach (var entry in entries)
        {
            if(entry is { Entity: ICreationTimeAuditable creation, State: EntityState.Added })
            {
                if(creation.CreatedAt is null || creation.CreatedAt == default)
                {
                    creation.CreatedAt = timeProvider.GetLocalNow();
                }
            }

            if (entry is { Entity: ILastUpdateTimeAuditable update, State: EntityState.Added or EntityState.Modified })
            {
                if (entry.Property(nameof(update.LastUpdatedAt)).IsModified) { }
                else if (update.LastUpdatedAt is null || update.LastUpdatedAt == default)
                {
                    update.LastUpdatedAt = timeProvider.GetLocalNow();
                }

                if (entry is { Entity: ICreationTimeAuditable, State: EntityState.Modified })
                {
                    entry.Property(nameof(ICreationTimeAuditable.CreatedAt)).IsModified = false;
                }
            }
        }
    }
}

操作人審計偵聽器

/// <summary>
/// 為操作人審計設置值,如果已經手動設置有效值,不會再次設置。如果有邏輯刪除實體,應該位於邏輯刪除攔截器之後。<br/>
/// 到此處依然處於刪除狀態的實體應該是物理刪除,記錄審計信息沒有意義。
/// </summary>
public class OperatorAuditableSaveChangesInterceptor<TIdentityKey> : SaveChangesInterceptor
    where TIdentityKey : struct, IEquatable<TIdentityKey>
{
    protected IServiceScopeFactory ScopeFactory { get; }

    /// <summary>
    /// 為操作人審計設置值,如果已經手動設置有效值,不會再次設置。如果有邏輯刪除實體,應該位於邏輯刪除攔截器之後。<br/>
    /// 到此處依然處於刪除狀態的實體應該是物理刪除,記錄審計信息沒有意義。
    /// </summary>
    /// <param name="scopeFactory"></param>
    public OperatorAuditableSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
    {
        ArgumentNullException.ThrowIfNull(scopeFactory);

        ScopeFactory = scopeFactory;
    }

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        OnSavingChanges(eventData);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        OnSavingChanges(eventData);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    /// <summary>
    /// 處理實體的審計操作人
    /// </summary>
    /// <param name="eventData"></param>
    protected virtual void OnSavingChanges(DbContextEventData eventData)
    {
        ArgumentNullException.ThrowIfNull(eventData.Context);

        using var scope = ScopeFactory.CreateScope();
        var operatorAccessor = scope.ServiceProvider.GetRequiredService<IOperatorAccessor<TIdentityKey>>();

        eventData.Context.ChangeTracker.DetectChanges();

        var entries = eventData.Context.ChangeTracker.Entries()
            .Where(static e => e.State is EntityState.Added or EntityState.Modified);

        foreach (var entry in entries)
        {
            if (entry is { Entity: ICreationUserAuditable<TIdentityKey> creation, State: EntityState.Added })
            {
                if (creation.CreatedById is null || creation.CreatedById.Value.Equals(default))
                {
                    creation.CreatedById = operatorAccessor.GetUserId();
                }
            }

            if (entry is { Entity: ILastUpdateUserAuditable<TIdentityKey> update, State: EntityState.Added or EntityState.Modified })
            {
                if (entry.Property(nameof(update.LastUpdatedById)).IsModified) { }
                else if (update.LastUpdatedById is null || update.LastUpdatedById.Value.Equals(default))
                {
                    update.LastUpdatedById = operatorAccessor.GetUserId();
                }

                if (entry is { Entity: ICreationUserAuditable<TIdentityKey>, State: EntityState.Modified })
                {
                    entry.Property(nameof(ICreationUserAuditable<TIdentityKey>.CreatedById)).IsModified = false;
                }
            }
        }
    }
}

/// <summary>
/// 實體操作人的用戶Id提供服務
/// </summary>
/// <typeparam name="TIdentityKey">用戶Id類型</typeparam>
public interface IOperatorAccessor<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
{
    /// <summary>
    /// 獲取用戶Id
    /// </summary>
    /// <returns>用戶Id</returns>
    TIdentityKey? GetUserId();

    /// <summary>
    /// 非同步獲取用戶Id
    /// </summary>
    /// <param name="cancellation">取消令牌</param>
    /// <returns>用戶Id</returns>
    Task<TIdentityKey?> GetUserIdAsync(CancellationToken cancellation = default);
}

/// <summary>
/// 使用Http上下文獲取實體操作人的用戶Id
/// </summary>
/// <typeparam name="TIdentityKey"><inheritdoc cref="IOperatorAccessor{TIdentityKey}"/></typeparam>
/// <param name="contextAccessor">Http上下文訪問器</param>
/// <param name="options">Identity選項</param>
public class HttpContextUserOperatorAccessor<TIdentityKey>(
    IHttpContextAccessor contextAccessor,
    IOptions<IdentityOptions> options)
    : IOperatorAccessor<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>, IParsable<TIdentityKey>
{
    public TIdentityKey? GetUserId()
    {
        var success = TIdentityKey.TryParse(contextAccessor.HttpContext?.User.Claims.FirstOrDefault(c => c.Type == options.Value.ClaimsIdentity.UserIdClaimType)!.Value, null, out var id);
        return success ? id : null;
    }

    public Task<TIdentityKey?> GetUserIdAsync(CancellationToken cancellation = default)
    {
        return Task.FromResult(GetUserId());
    }
}

實體操作人的獲取在定義偵聽器的時候是未知的,所以獲取方式需要通過介面從外部傳入。此處以用ASP.NET Core Identity獲取用戶Id為例。

偵聽器統一使用作用域工廠服務使其能和依賴註入系統緊密配合,然後使用內部作用域即用即取,用完立即銷毀的方式避免記憶體泄露。

配置服務

一切準備妥當後就可以在主應用里配置相關服務讓功能可以正常運行。

public void ConfigureServices(IServiceCollection services)
{
    // 實體操作人審計EF Core攔截器需要使用此服務獲取操作人信息
    services.AddScoped(typeof(IOperatorAccessor<>), typeof(HttpContextUserOperatorAccessor<>));

    // 註冊基於緩衝池的資料庫上下文工廠
    services.AddPooledDbContextFactory<ApplicationDbContext>((sp, options) =>
    {
        // 註冊攔截器
        var scopeFactory = sp.GetRequiredService<IServiceScopeFactory>();
        options.AddInterceptors(
            new OperationTimeAuditableSaveChangesInterceptor(scopeFactory),
            new IdentityOptimisticConcurrencySupportedSaveChangesInterceptor(scopeFactory),
            new OperatorAuditableSaveChangesInterceptor<IdentityKey>(scopeFactory));

        // 其它代碼
    });

    // 其它代碼
}

由於攔截器對象是長期存在且脫離依賴註入的特殊對象,因此需要從外部傳入作用域工廠使其能夠使用依賴註入的相關功能和整個ASP.NET Core應用更緊密的集成。攔截器和ASP.NET Core中間件一樣順序會影響結果,因此要認真考慮如何安排。

結語

如此一番操作之後,操作時間、操作用戶審計和樂觀併發就全自動化了,一般業務代碼可以0修改完成集成。如果手動操作相關屬性,偵聽器也會優先採用手動操作的結果保持充足的靈活性。

示例代碼:SoftDeleteDemo.rar。主頁顯示異常請在libman.json上右鍵恢復前端包。

QQ群

讀者交流QQ群:540719365
image

歡迎讀者和廣大朋友一起交流,如發現本書錯誤也歡迎通過博客園、QQ群等方式告知筆者。

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


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 字元串轉換為數字int.TryParse() bool success = int.TryParse("300",out int b); Console.WriteLine(success); // 輸出為 true Console.WriteLine(b); //輸出為 300 字元串里的“300 ...
  • 前言 資料庫併發,數據審計和軟刪除一直是數據持久化方面的經典問題。早些時候,這些工作需要手寫複雜的SQL或者通過存儲過程和觸發器實現。手寫複雜SQL對軟體可維護性構成了相當大的挑戰,隨著SQL字數的變多,用到的嵌套和複雜語法增加,可讀性和可維護性的難度是幾何級暴漲。因此如何在實現功能的同時控制這些S ...
  • AutoFixture是一個.NET庫,旨在簡化單元測試中的數據設置過程。通過自動生成測試數據,它幫助開發者減少測試代碼的編寫量,使得單元測試更加簡潔、易讀和易維護。AutoFixture可以用於任何.NET測試框架,如xUnit、NUnit或MSTest。 預設情況下AutoFixture生成的字 ...
  • 閱讀了 https://devblogs.microsoft.com/dotnet/configureawait-faq/,感覺其對於 .NET 非同步編程有非常有意義的指導,對於進一步學習和理解 .NET 非同步編程非常友邦做,所以進行翻譯以供參考學習。 七年多前,.NET 在語言和庫中加入了 asy ...
  • 前置 連接概述 連接是由兩個點之間創建的。Source和Target依賴屬性是Point類型,通常綁定到連接器的Anchor點。 基本連接 庫中所有連接的基類是BaseConnection,它派生自Shape。在創建自定義連接時,可以不受任何限值地從BaseConnection派生。 它公開了兩個命 ...
  • 前言 資料庫併發,數據審計和軟刪除一直是數據持久化方面的經典問題。早些時候,這些工作需要手寫複雜的SQL或者通過存儲過程和觸發器實現。手寫複雜SQL對軟體可維護性構成了相當大的挑戰,隨著SQL字數的變多,用到的嵌套和複雜語法增加,可讀性和可維護性的難度是幾何級暴漲。因此如何在實現功能的同時控制這些S ...
  • 在日常開發中,我們經常需要和文件打交道,特別是桌面開發,有時候就會需要載入大批量的文件,而且可能還會存在部分文件缺失的情況,那麼如何才能快速的判斷文件是否存在呢?如果處理不當的,且文件數量比較多的時候,可能會造成卡頓等情況,進而影響程式的使用體驗。今天就以一個簡單的小例子,簡述兩種不同的判斷文件是否... ...
  • 類型檢查和轉換:當你需要檢查對象是否為特定類型,並且希望在同一時間內將其轉換為那個類型時,模式匹配提供了一種更簡潔的方式來完成這一任務,避免了使用傳統的as和is操作符後還需要進行額外的null檢查。 複雜條件邏輯:在處理複雜的條件邏輯時,特別是涉及到多個條件和類型的情況下,使用模式匹配可以使代碼更 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...