다음을 통해 공유


인터셉터

EF Core(Entity Framework Core) 인터셉터는 EF Core 작업의 가로채기, 수정 및/또는 억제를 가능하게 합니다. 여기에는 명령 실행과 같은 하위 수준 데이터베이스 작업뿐만 아니라 SaveChanges 호출과 같은 상위 수준 작업이 포함됩니다.

인터셉터는 로그 및 진단과는 달리 가로채진 작업을 수정하거나 억제할 수 있습니다. 간단한 로깅 또는 Microsoft.Extensions.Logging 은 로깅에 더 적합합니다.

인터셉터는 컨텍스트가 구성될 때 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는 각 인터페이스에 대한 이벤트를 적절하게 라우팅합니다.

데이터베이스 가로채기

비고

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

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

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

기본 클래스는 DbCommandInterceptorDbConnectionInterceptorDbTransactionInterceptor 해당 인터페이스의 각 메서드에 대한 no-op 구현을 포함합니다. 기본 클래스를 사용하여 사용하지 않는 인터셉션 메서드를 구현할 필요가 없습니다.

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

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

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

팁 (조언)

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

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

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

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

이 태그는 명령 텍스트의 첫 번째 줄에 주석으로 항상 포함되므로 인터셉터에서 검색할 수 있습니다. 태그를 검색할 때 적절한 힌트를 추가하도록 쿼리 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로부터 상속받습니다.
  • 인터셉터에서는 동기화 및 비동기 메서드를 모두 구현합니다. 이렇게 하면 동기화 및 비동기 쿼리에 동일한 쿼리 힌트가 적용됩니다.
  • 인터셉터에서는 데이터베이스로 전송Executing 생성된 SQL을 사용하여 EF Core에서 호출하는 메서드를 구현 합니다. 데이터베이스 호출이 반환된 후 호출되는 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을 참조하세요.

경고

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

경고

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

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

팁 (조언)

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

EF Core 인터셉터는 다음을 수행할 수 있습니다.

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

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

경고

이러한 방식으로 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

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

변경 저장 작업 가로채기

팁 (조언)

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;
}

동기화 및 비동기 메서드를 모두 재정의하면 호출 여부에 SaveChangesSaveChangesAsync 관계없이 감사가 수행됩니다. 또한 비동기 오버로드 자체는 비차단식 비동기 I/O를 감사용 데이터베이스에 수행할 수 있습니다. 모든 데이터베이스 I/O가 비동기인지 확인하기 위해 동기화 SavingChanges 메서드에서 throw할 수 있습니다. 이렇게 하려면 애플리케이션이 항상 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}' ");
}

그 결과는 SaveChangesAudit 엔터티이며, 각 삽입, 업데이트 또는 삭제에 대해 하나씩 EntityAudit 엔터티의 컬렉션이 포함됩니다. 그런 다음 인터셉터에서 이러한 엔터티를 감사 데이터베이스에 삽입합니다.

팁 (조언)

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

성공 탐지

감사 엔터티는 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;
}

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

오류 감지

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

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 = await context.Blogs.Include(e => e.Posts).SingleAsync();

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

    await context.SaveChangesAsync();
}

// 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 await context.SaveChangesAudits.Include(e => e.Entities).ToListAsync())
    {
        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'.