Share via


인터셉터

EF Core(Entity Framework Core) 인터셉터를 사용하면 EF Core 작업의 인터셉션, 수정 및/또는 해제할 수 있습니다. 여기에는 명령 실행과 같은 하위 수준 데이터베이스 작업과 SaveChanges 호출 등의 상위 수준 작업이 포함됩니다.

인터셉터는 인터셉트되는 작업을 수정하거나 제거할 수 있다는 점에서 로깅 및 진단과 다릅니다. 간단한 로깅 또는 Microsoft.Extensions.Logging이 로깅에 더 적합합니다.

인터셉터는 컨텍스트가 구성될 때 DbContext 인스턴스별로 등록됩니다. 진단 수신기를 사용하여 프로세스의 모든 DbContext 인스턴스에 대해 동일한 정보를 가져옵니다.

인터셉터 등록

인터셉터는 DbContext 인스턴스를 구성할 때 AddInterceptors를 사용하여 등록됩니다. 이 구성은 일반적으로 DbContext.OnConfiguring의 재정의에서 수행됩니다. 예시:

public class ExampleContext : BlogsContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}

AddInterceptorsAddDbContext 또는 DbContextOptions의 일부로 또는 DbContext 생성자에 전달할 인스턴스를 만들 때 호출할 수 있습니다.

AddDbContext를 사용하거나 DbContextOptions 인스턴스가 DbContext 생성자에 전달될 때 OnConfiguring은 여전히 호출됩니다. 따라서 DbContext가 생성되는 방식에 관계없이 컨텍스트 구성을 적용하기에 이상적인 위치입니다.

인터셉터는 종종 상태 비저장이므로 모든 DbContext 인스턴스에 단일 인터셉터 인스턴스를 사용할 수 있습니다. 예시:

public class TaggedQueryCommandInterceptorContext : BlogsContext
{
    private static readonly TaggedQueryCommandInterceptor _interceptor
        = new TaggedQueryCommandInterceptor();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(_interceptor);
}

모든 인터셉터 인스턴스는 IInterceptor에서 파생된 하나 이상의 인터페이스를 구현해야 합니다. 각 인스턴스는 여러 가로채기 인터페이스를 구현하는 경우에도 한 번만 등록해야 합니다. EF Core는 각 인터페이스에 대한 이벤트를 적절하게 라우팅합니다.

데이터베이스 가로채기

참고 항목

데이터베이스 가로채기는 관계형 데이터베이스 공급자에 대해서만 사용할 수 있습니다.

하위 수준 데이터베이스 가로채기는 다음 표에 표시된 세 가지 인터페이스로 분할됩니다.

interceptor(인터셉터) 가로채는 데이터베이스 작업
IDbCommandInterceptor 명령 만들기
명령 실행
명령 오류
명령의 DbDataReader 삭제
IDbConnectionInterceptor 연결 열기 및 닫기
연결 실패
IDbTransactionInterceptor 트랜잭션 만들기
기존 트랜잭션 사용
트랜잭션 커밋
트랜잭션 롤백
트랜잭션 만들기 및 저장점 사용
트랜잭션 실패

기본 클래스 DbCommandInterceptor, DbConnectionInterceptor, DbTransactionInterceptor 는 해당 인터페이스의 각 메서드에 대한 no-op 구현을 포함합니다. 사용하지 않는 가로채기 메서드를 구현할 필요가 없도록 기본 클래스를 사용합니다.

각 인터셉터 형식의 메서드는 쌍으로 제공되며, 첫 번째 메서드는 데이터베이스 작업이 시작되기 전에 호출되고 두 번째는 작업이 완료된 후 호출됩니다. 예를 들어 DbCommandInterceptor.ReaderExecuting는 쿼리가 실행되기 전에 호출되고 DbCommandInterceptor.ReaderExecuted는 쿼리가 데이터베이스로 전송된 후에 호출됩니다.

각 메서드 쌍에는 동기화 및 비동기 변형이 모두 있습니다. 이렇게 하면 액세스 토큰 요청과 같은 비동기 I/O가 비동기 데이터베이스 작업을 가로챌 때 발생할 수 있습니다.

예제: 쿼리 힌트를 추가하는 명령 가로채기

GitHub에서 명령 인터셉터 샘플을 다운로드할 수 있습니다.

IDbCommandInterceptor는 데이터베이스로 전송되기 전에 SQL을 수정하는 데 사용할 수 있습니다. 이 예제에서는 쿼리 힌트를 포함하도록 SQL을 수정하는 방법을 보여 줍니다.

종종 가로채기에서 가장 까다로운 부분은 명령이 수정해야 하는 쿼리에 해당하는 시기를 결정하는 것입니다. SQL 구문 분석이 한 가지 옵션이지만 취약한 경향이 있습니다. 또 다른 옵션은 EF Core 쿼리 태그를 사용하여 수정해야 하는 각 쿼리에 태그를 지정하는 것입니다. 예시:

var blogs1 = context.Blogs.TagWith("Use hint: robust plan").ToList();

이 태그는 명령 텍스트의 첫 번째 줄에 주석으로 항상 포함되므로 인터셉터에서 검색할 수 있습니다. 태그를 검색할 때 적절한 힌트를 추가하도록 쿼리 SQL이 수정됩니다.

public class TaggedQueryCommandInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        ManipulateCommand(command);

        return result;
    }

    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        ManipulateCommand(command);

        return new ValueTask<InterceptionResult<DbDataReader>>(result);
    }

    private static void ManipulateCommand(DbCommand command)
    {
        if (command.CommandText.StartsWith("-- Use hint: robust plan", StringComparison.Ordinal))
        {
            command.CommandText += " OPTION (ROBUST PLAN)";
        }
    }
}

참고:

  • 인터셉터는 인터페이스의 모든 메서드를 구현할 필요가 없도록 DbCommandInterceptor에서 상속합니다.
  • 인터셉터에 동기화 메서드와 비동기 메서드가 모두 있습니다. 이렇게 하면 동기화 및 비동기 쿼리에 동일한 쿼리 힌트가 적용됩니다.
  • 인터셉터에서는 데이터베이스로 전송되기 전에 생성된 SQL을 사용하여 EF Core에서 호출하는 Executing 메서드를 구현합니다. 데이터베이스 호출이 반환된 후 호출되는 Executed 메서드와 대조합니다.

이 예제에서 코드를 실행하면 쿼리에 태그가 지정되면 다음이 생성됩니다.

-- Use hint: robust plan

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b] OPTION (ROBUST PLAN)

반면에 쿼리에 태그가 지정되지 않은 경우 수정되지 않은 데이터베이스로 전송됩니다.

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]

예: AAD를 사용하는 SQL Azure 인증에 대한 연결 가로채기

GitHub에서 연결 인터셉터 샘플을 다운로드할 수 있습니다.

IDbConnectionInterceptor는 데이터베이스에 연결하는 데 사용되기 전에 DbConnection을 조작하는 데 사용할 수 있습니다. AAD(Azure Active Directory) 액세스 토큰을 가져오는 데 사용할 수 있습니다. 예시:

public class AadAuthenticationInterceptor : DbConnectionInterceptor
{
    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new InvalidOperationException("Open connections asynchronously when using AAD authentication.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
    {
        var sqlConnection = (SqlConnection)connection;

        var provider = new AzureServiceTokenProvider();
        // Note: in some situations the access token may not be cached automatically the Azure Token Provider.
        // Depending on the kind of token requested, you may need to implement your own caching here.
        sqlConnection.AccessToken = await provider.GetAccessTokenAsync("https://database.windows.net/", null, cancellationToken);

        return result;
    }
}

Microsoft.Data.SqlClient는 이제 연결 문자열을 통해 AAD 인증을 지원합니다. 자세한 내용은 SqlAuthenticationMethod 을 참조하세요.

Warning

연결을 열기 위해 동기화 호출이 이루어지면 인터셉터에서 throw됩니다. 이는 액세스 토큰을 가져올 비동기 메서드가 없고 교착 상태를 위험하지 않고 비동기 컨텍스트에서 비동기 메서드를 호출하는 범용적이고 간단한 방법이 없기 때문입니다.

Warning

경우에 따라 액세스 토큰이 Azure 토큰 공급자에 자동으로 캐시되지 않을 수 있습니다. 요청된 토큰의 종류에 따라 여기에서 사용자 고유의 캐싱을 구현해야 할 수 있습니다.

예: 캐싱을 위한 고급 명령 가로채기

GitHub에서 고급 명령 인터셉터 샘플을 다운로드할 수 있습니다.

EF Core 인터셉터 등록

  • 가로채는 작업을 실행하지 않도록 EF Core에 지시
  • EF Core로 다시 보고된 작업 결과 변경

이 예제에서는 이러한 기능을 사용하여 기본 2단계 캐시처럼 동작하는 인터셉터를 보여줍니다. 데이터베이스 왕복을 방지하여 특정 쿼리에 대해 캐시된 쿼리 결과가 반환됩니다.

Warning

이러한 방식으로 EF Core 기본 동작을 변경할 때는 주의해야 합니다. EF Core는 올바르게 처리할 수 없는 비정상적인 결과를 가져오는 경우 예기치 않은 방식으로 동작할 수 있습니다. 또한 이 예제에서는 인터셉터 개념을 보여 줍니다. 강력한 2단계 캐시 구현을 위한 템플릿으로는 사용되지 않습니다.

이 예제에서 애플리케이션은 쿼리를 자주 실행하여 가장 최근의 "일별 메시지"를 가져옵니다.

async Task<string> GetDailyMessage(DailyMessageContext context)
    => (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;

이 쿼리는 인터셉터에서 쉽게 검색할 수 있도록 태그가 지정됩니다. 데이터베이스에서 새 메시지를 매일 한 번만 쿼리하는 것이 좋습니다. 다른 시간에 애플리케이션은 캐시된 결과를 사용합니다. (샘플에서는 샘플에서 10초의 지연 시간을 사용하여 새 날을 시뮬레이션합니다.)

인터셉터 상태

이 인터셉터 상태 저장: 쿼리된 가장 최근 일별 메시지의 ID 및 메시지 텍스트와 해당 쿼리가 실행된 시간을 저장합니다. 이 상태 때문에 캐싱에는 여러 컨텍스트 인스턴스에서 동일한 인터셉터를 사용해야 하므로 잠금도 필요합니다.

private readonly object _lock = new object();
private int _id;
private string _message;
private DateTime _queriedAt;

실행 전에 발생합니다.

Executing 메서드(즉, 데이터베이스 호출 전)에서 인터셉터에서 태그가 지정된 쿼리를 검색한 다음 캐시된 결과가 있는지 확인합니다. 이러한 결과가 발견되면 쿼리가 표시되지 않고 캐시된 결과가 대신 사용됩니다.

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal))
    {
        lock (_lock)
        {
            if (_message != null
                && DateTime.UtcNow < _queriedAt + new TimeSpan(0, 0, 10))
            {
                command.CommandText = "-- Get_Daily_Message: Skipping DB call; using cache.";
                result = InterceptionResult<DbDataReader>.SuppressWithResult(new CachedDailyMessageDataReader(_id, _message));
            }
        }
    }

    return new ValueTask<InterceptionResult<DbDataReader>>(result);
}

코드가 InterceptionResult<TResult>.SuppressWithResult를 호출하고 캐시된 데이터를 포함하는 대체 DbDataReader를 전달하는 방법을 확인합니다. 그런 다음 이 InterceptionResult가 반환되어 쿼리 실행이 표시되지 않습니다. 대체 판독기는 대신 EF Core에서 쿼리 결과로 사용됩니다.

이 인터셉터도 명령 텍스트를 조작합니다. 이 조작은 필요하지 않지만 로그 메시지의 명확성을 향상시킵니다. 이제 쿼리가 실행되지 않으므로 명령 텍스트가 유효한 SQL일 필요는 없습니다.

실행 후

캐시된 메시지를 사용할 수 없거나 만료된 경우 위의 코드는 결과를 표시하지 않습니다. 따라서 EF Core는 쿼리를 정상적으로 실행합니다. 그런 다음 실행 후 인터셉터의 Executed 메서드로 돌아갑니다. 이 시점에서 결과가 아직 캐시된 판독기가 아닌 경우 새 메시지 ID와 문자열이 실제 판독기에서 추출되고 이 쿼리의 다음 사용을 위해 캐시됩니다.

public override async ValueTask<DbDataReader> ReaderExecutedAsync(
    DbCommand command,
    CommandExecutedEventData eventData,
    DbDataReader result,
    CancellationToken cancellationToken = default)
{
    if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal)
        && !(result is CachedDailyMessageDataReader))
    {
        try
        {
            await result.ReadAsync(cancellationToken);

            lock (_lock)
            {
                _id = result.GetInt32(0);
                _message = result.GetString(1);
                _queriedAt = DateTime.UtcNow;
                return new CachedDailyMessageDataReader(_id, _message);
            }
        }
        finally
        {
            await result.DisposeAsync();
        }
    }

    return result;
}

데모

캐싱 인터셉터 샘플에는 캐싱을 테스트하기 위해 매일 메시지를 쿼리하는 간단한 콘솔 애플리케이션이 포함되어 있습니다.

// 1. Initialize the database with some daily messages.
using (var context = new DailyMessageContext())
{
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();

    context.AddRange(
        new DailyMessage { Message = "Remember: All builds are GA; no builds are RTM." },
        new DailyMessage { Message = "Keep calm and drink tea" });

    await context.SaveChangesAsync();
}

// 2. Query for the most recent daily message. It will be cached for 10 seconds.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

// 3. Insert a new daily message.
using (var context = new DailyMessageContext())
{
    context.Add(new DailyMessage { Message = "Free beer for unicorns" });

    await context.SaveChangesAsync();
}

// 4. Cached message is used until cache expires.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

// 5. Pretend it's the next day.
Thread.Sleep(10000);

// 6. Cache is expired, so the last message will not be queried again.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

async Task<string> GetDailyMessage(DailyMessageContext context)
    => (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;

그 결과는 다음과 같이 출력됩니다.

info: 10/15/2020 12:32:11.801 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message

      SELECT "d"."Id", "d"."Message"
      FROM "DailyMessages" AS "d"
      ORDER BY "d"."Id" DESC
      LIMIT 1

Keep calm and drink tea

info: 10/15/2020 12:32:11.821 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='Free beer for unicorns' (Size = 22)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "DailyMessages" ("Message")
      VALUES (@p0);
      SELECT "Id"
      FROM "DailyMessages"
      WHERE changes() = 1 AND "rowid" = last_insert_rowid();

info: 10/15/2020 12:32:11.826 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message: Skipping DB call; using cache.

Keep calm and drink tea

info: 10/15/2020 12:32:21.833 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message

      SELECT "d"."Id", "d"."Message"
      FROM "DailyMessages" AS "d"
      ORDER BY "d"."Id" DESC
      LIMIT 1

Free beer for unicorns

로그 출력에서 애플리케이션은 시간 제한이 만료될 때까지 캐시된 메시지를 계속 사용하며, 이때 데이터베이스가 새 메시지에 대해 다시 쿼리됩니다.

SaveChanges 인터셉션

GitHub에서 SaveChanges 인터셉터 샘플을 다운로드할 수 있습니다.

SaveChangesSaveChangesAsync 인터셉션 지점은 ISaveChangesInterceptor 인터페이스에 의해 정의됩니다. 다른 인터셉터에 관해서는 no-op 메서드가 있는 SaveChangesInterceptor 기본 클래스는 편의를 위해 제공됩니다.

인터셉터는 강력합니다. 그러나 대부분의 경우 SaveChanges 메서드를 재정의하거나 DbContext에 노출된 SaveChanges를 위한 .NET 이벤트를 사용하는 것이 더 쉬울 수 있습니다.

예: 감사에 대한 SaveChanges 가로채기

SaveChanges를 가로채 변경 내용에 대한 독립적인 감사 레코드를 만들 수 있습니다.

참고 항목

이는 강력한 감사 솔루션이 아닙니다. 오히려 가로채기의 기능을 보여 주는 데 사용되는 간단한 예입니다.

애플리케이션 컨텍스트입니다.

감사 샘플에서는 블로그 및 게시물이 포함된 간단한 DbContext를 사용합니다.

public class BlogsContext : DbContext
{
    private readonly AuditingInterceptor _auditingInterceptor = new AuditingInterceptor("DataSource=audit.db");

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_auditingInterceptor)
            .UseSqlite("DataSource=blogs.db");

    public DbSet<Blog> Blogs { get; set; }
}

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }

    public ICollection<Post> Posts { get; } = new List<Post>();
}

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }

    public Blog Blog { get; set; }
}

각 DbContext 인스턴스에 대해 인터셉터의 새 인스턴스가 등록됩니다. 감사 인터셉터에 현재 컨텍스트 인스턴스에 연결된 상태가 포함되어 있기 때문입니다.

감사 컨텍스트

이 샘플에는 감사 데이터베이스에 사용되는 두 번째 DbContext 및 모델도 포함되어 있습니다.

public class AuditContext : DbContext
{
    private readonly string _connectionString;

    public AuditContext(string connectionString)
    {
        _connectionString = connectionString;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.UseSqlite(_connectionString);

    public DbSet<SaveChangesAudit> SaveChangesAudits { get; set; }
}

public class SaveChangesAudit
{
    public int Id { get; set; }
    public Guid AuditId { get; set; }
    public DateTime StartTime { get; set; }
    public DateTime EndTime { get; set; }
    public bool Succeeded { get; set; }
    public string ErrorMessage { get; set; }

    public ICollection<EntityAudit> Entities { get; } = new List<EntityAudit>();
}

public class EntityAudit
{
    public int Id { get; set; }
    public EntityState State { get; set; }
    public string AuditMessage { get; set; }

    public SaveChangesAudit SaveChangesAudit { get; set; }
}

인터셉터

인터셉터를 사용하여 감사하는 일반적인 개념은 다음과 같습니다.

  • 감사 메시지는 SaveChanges의 시작 부분에 만들어지고 감사 데이터베이스에 기록됩니다.
  • SaveChanges를 계속할 수 있습니다.
  • SaveChanges가 성공하면 감사 메시지가 업데이트되어 성공을 나타냅니다.
  • SaveChanges가 실패하면 감사 메시지가 업데이트되어 실패를 나타냅니다.

첫 번째 단계는 ISaveChangesInterceptor.SavingChangesISaveChangesInterceptor.SavingChangesAsync의 재정의를 사용하여 변경 내용을 데이터베이스로 보내기 전에 처리됩니다.

public async ValueTask<InterceptionResult<int>> SavingChangesAsync(
    DbContextEventData eventData,
    InterceptionResult<int> result,
    CancellationToken cancellationToken = default)
{
    _audit = CreateAudit(eventData.Context);

    using var auditContext = new AuditContext(_connectionString);

    auditContext.Add(_audit);
    await auditContext.SaveChangesAsync();

    return result;
}

public InterceptionResult<int> SavingChanges(
    DbContextEventData eventData,
    InterceptionResult<int> result)
{
    _audit = CreateAudit(eventData.Context);

    using var auditContext = new AuditContext(_connectionString);
    auditContext.Add(_audit);
    auditContext.SaveChanges();

    return result;
}

동기화 및 비동기 메서드를 모두 재정의하면 SaveChanges 또는 SaveChangesAsync 호출 여부에 관계없이 감사가 수행됩니다. 또한 비동기 오버로드 자체는 감사 데이터베이스에 대한 비차단 비동기 I/O를 수행할 수 있습니다. 동기화 SavingChanges 메서드에서 를 throw하여 모든 데이터베이스 I/O가 비동기인지 확인할 수 있습니다. 그러면 애플리케이션이 항상 SaveChangesAsync를 호출하고 SaveChanges를 호출하지 않도록 해야 합니다.

로그온 실패 감사 메시지입니다.

모든 인터셉터 메서드에는 가로채는 이벤트에 대한 컨텍스트 정보를 제공하는 eventData 매개 변수가 있습니다. 이 경우 현재 애플리케이션 DbContext가 이벤트 데이터에 포함되며 감사 메시지를 만드는 데 사용됩니다.

private static SaveChangesAudit CreateAudit(DbContext context)
{
    context.ChangeTracker.DetectChanges();

    var audit = new SaveChangesAudit { AuditId = Guid.NewGuid(), StartTime = DateTime.UtcNow };

    foreach (var entry in context.ChangeTracker.Entries())
    {
        var auditMessage = entry.State switch
        {
            EntityState.Deleted => CreateDeletedMessage(entry),
            EntityState.Modified => CreateModifiedMessage(entry),
            EntityState.Added => CreateAddedMessage(entry),
            _ => null
        };

        if (auditMessage != null)
        {
            audit.Entities.Add(new EntityAudit { State = entry.State, AuditMessage = auditMessage });
        }
    }

    return audit;

    string CreateAddedMessage(EntityEntry entry)
        => entry.Properties.Aggregate(
            $"Inserting {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");

    string CreateModifiedMessage(EntityEntry entry)
        => entry.Properties.Where(property => property.IsModified || property.Metadata.IsPrimaryKey()).Aggregate(
            $"Updating {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");

    string CreateDeletedMessage(EntityEntry entry)
        => entry.Properties.Where(property => property.Metadata.IsPrimaryKey()).Aggregate(
            $"Deleting {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
}

그 결과 EntityAudit 엔터티 컬렉션이 있는 SaveChangesAudit 엔터티가 삽입, 업데이트 또는 삭제할 때마다 하나씩 생성됩니다. 그런 다음 인터셉터에서 이러한 엔터티를 감사 데이터베이스에 삽입합니다.

ToString은 모든 EF Core 이벤트 데이터 클래스에서 재정의되어 이벤트에 해당하는 로그 메시지를 생성합니다. 예를 들어 ContextInitializedEventData.ToString을 호출하면 "Entity Framework Core 5.0.0이 공급자 'Microsoft.EntityFrameworkCore.Sqlite'를 사용하여 'BlogsContext'를 초기화하고 옵션: 없음"이 생성됩니다.

성공 검색

감사 엔터티는 인터셉터에 저장되므로 SaveChanges가 성공하거나 실패하면 다시 액세스할 수 있습니다. 성공한 경우 ISaveChangesInterceptor.SavedChanges 또는 ISaveChangesInterceptor.SavedChangesAsync가 호출됩니다.

public int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = true;
    _audit.EndTime = DateTime.UtcNow;

    auditContext.SaveChanges();

    return result;
}

public async ValueTask<int> SavedChangesAsync(
    SaveChangesCompletedEventData eventData,
    int result,
    CancellationToken cancellationToken = default)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = true;
    _audit.EndTime = DateTime.UtcNow;

    await auditContext.SaveChangesAsync(cancellationToken);

    return result;
}

감사 엔터티는 이미 데이터베이스에 있고 업데이트해야 하므로 감사 컨텍스트에 연결됩니다. 그런 다음, SucceededEndTime을 설정하여 이러한 속성을 수정된 것으로 표시하여 SaveChanges가 감사 데이터베이스에 업데이트를 보냅니다.

오류 감지

실패는 성공과 거의 동일한 방식으로 처리되지만 ISaveChangesInterceptor.SaveChangesFailed 또는 ISaveChangesInterceptor.SaveChangesFailedAsync 메서드에서는 처리됩니다. 이벤트 데이터에는 throw된 예외가 포함됩니다.

public void SaveChangesFailed(DbContextErrorEventData eventData)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = false;
    _audit.EndTime = DateTime.UtcNow;
    _audit.ErrorMessage = eventData.Exception.Message;

    auditContext.SaveChanges();
}

public async Task SaveChangesFailedAsync(
    DbContextErrorEventData eventData,
    CancellationToken cancellationToken = default)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = false;
    _audit.EndTime = DateTime.UtcNow;
    _audit.ErrorMessage = eventData.Exception.InnerException?.Message;

    await auditContext.SaveChangesAsync(cancellationToken);
}

데모

감사 샘플에는 블로깅 데이터베이스를 변경한 다음 생성된 감사를 표시하는 간단한 콘솔 애플리케이션이 포함되어 있습니다.

// Insert, update, and delete some entities

using (var context = new BlogsContext())
{
    context.Add(
        new Blog { Name = "EF Blog", Posts = { new Post { Title = "EF Core 3.1!" }, new Post { Title = "EF Core 5.0!" } } });

    await context.SaveChangesAsync();
}

using (var context = new BlogsContext())
{
    var blog = context.Blogs.Include(e => e.Posts).Single();

    blog.Name = "EF Core Blog";
    context.Remove(blog.Posts.First());
    blog.Posts.Add(new Post { Title = "EF Core 6.0!" });

    context.SaveChanges();
}

// Do an insert that will fail

using (var context = new BlogsContext())
{
    try
    {
        context.Add(new Post { Id = 3, Title = "EF Core 3.1!" });

        await context.SaveChangesAsync();
    }
    catch (DbUpdateException)
    {
    }
}

// Look at the audit trail

using (var context = new AuditContext("DataSource=audit.db"))
{
    foreach (var audit in context.SaveChangesAudits.Include(e => e.Entities).ToList())
    {
        Console.WriteLine(
            $"Audit {audit.AuditId} from {audit.StartTime} to {audit.EndTime} was{(audit.Succeeded ? "" : " not")} successful.");

        foreach (var entity in audit.Entities)
        {
            Console.WriteLine($"  {entity.AuditMessage}");
        }

        if (!audit.Succeeded)
        {
            Console.WriteLine($"  Error: {audit.ErrorMessage}");
        }
    }
}

결과는 감사 데이터베이스의 내용을 보여 줍니다.

Audit 52e94327-1767-4046-a3ca-4c6b1eecbca6 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
  Inserting Blog with Id: '-2147482647' Name: 'EF Blog'
  Inserting Post with Id: '-2147482647' BlogId: '-2147482647' Title: 'EF Core 3.1!'
  Inserting Post with Id: '-2147482646' BlogId: '-2147482647' Title: 'EF Core 5.0!'
Audit 8450f57a-5030-4211-a534-eb66b8da7040 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
  Inserting Post with Id: '-2147482645' BlogId: '1' Title: 'EF Core 6.0!'
  Updating Blog with Id: '1' Name: 'EF Core Blog'
  Deleting Post with Id: '1'
Audit 201fef4d-66a7-43ad-b9b6-b57e9d3f37b3 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was not successful.
  Inserting Post with Id: '3' BlogId: '' Title: 'EF Core 3.1!'
  Error: SQLite Error 19: 'UNIQUE constraint failed: Post.Id'.