論如何直接用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 微服務框架,幫助我們輕鬆構建和管理微服務應用。 本框架不僅支持 Consul 服務註 ...
  • 先看一下效果吧: 如果不會寫動畫或者懶得寫動畫,就直接交給Blend來做吧; 其實Blend操作起來很簡單,有點類似於在操作PS,我們只需要設置關鍵幀,滑鼠點來點去就可以了,Blend會自動幫我們生成我們想要的動畫效果. 第一步:要創建一個空的WPF項目 第二步:右鍵我們的項目,在最下方有一個,在B ...
  • Prism:框架介紹與安裝 什麼是Prism? Prism是一個用於在 WPF、Xamarin Form、Uno 平臺和 WinUI 中構建鬆散耦合、可維護和可測試的 XAML 應用程式框架 Github https://github.com/PrismLibrary/Prism NuGet htt ...
  • 在WPF中,屏幕上的所有內容,都是通過畫筆(Brush)畫上去的。如按鈕的背景色,邊框,文本框的前景和形狀填充。藉助畫筆,可以繪製頁面上的所有UI對象。不同畫筆具有不同類型的輸出( 如:某些畫筆使用純色繪製區域,其他畫筆使用漸變、圖案、圖像或繪圖)。 ...
  • 前言 嗨,大家好!推薦一個基於 .NET 8 的高併發微服務電商系統,涵蓋了商品、訂單、會員、服務、財務等50多種實用功能。 項目不僅使用了 .NET 8 的最新特性,還集成了AutoFac、DotLiquid、HangFire、Nlog、Jwt、LayUIAdmin、SqlSugar、MySQL、 ...
  • 本文主要介紹攝像頭(相機)如何採集數據,用於類似攝像頭本地顯示軟體,以及流媒體數據傳輸場景如傳屏、視訊會議等。 攝像頭採集有多種方案,如AForge.NET、WPFMediaKit、OpenCvSharp、EmguCv、DirectShow.NET、MediaCaptre(UWP),網上一些文章以及 ...
  • 前言 Seal-Report 是一款.NET 開源報表工具,擁有 1.4K Star。它提供了一個完整的框架,使用 C# 編寫,最新的版本採用的是 .NET 8.0 。 它能夠高效地從各種資料庫或 NoSQL 數據源生成日常報表,並支持執行複雜的報表任務。 其簡單易用的安裝過程和直觀的設計界面,我們 ...
  • 背景需求: 系統需要對接到XXX官方的API,但因此官方對接以及管理都十分嚴格。而本人部門的系統中包含諸多子系統,系統間為了穩定,程式間多數固定Token+特殊驗證進行調用,且後期還要提供給其他兄弟部門系統共同調用。 原則上:每套系統都必須單獨接入到官方,但官方的接入複雜,還要官方指定機構認證的證書 ...
  • 本文介紹下電腦設備關機的情況下如何通過網路喚醒設備,之前電源S狀態 電腦Power電源狀態- 唐宋元明清2188 - 博客園 (cnblogs.com) 有介紹過遠程喚醒設備,後面這倆天瞭解多了點所以單獨加個隨筆 設備關機的情況下,使用網路喚醒的前提條件: 1. 被喚醒設備需要支持這WakeOnL ...
  • 前言 大家好,推薦一個.NET 8.0 為核心,結合前端 Vue 框架,實現了前後端完全分離的設計理念。它不僅提供了強大的基礎功能支持,如許可權管理、代碼生成器等,還通過採用主流技術和最佳實踐,顯著降低了開發難度,加快了項目交付速度。 如果你需要一個高效的開發解決方案,本框架能幫助大家輕鬆應對挑戰,實 ...