Utilisation de EF Core (ou de tout ORM), je souhaite garder une trace du nombre de requêtes que l’ORM adresse à la base de données lors de certaines opérations effectuées dans mon logiciel.
J'ai utilisé SQLAlchemy sous Python plus tôt, et sur cette pile, c'est facile à configurer. J'ai généralement des tests unitaires qui vérifient le nombre de requêtes effectuées pour un scénario, par rapport à un in -mémoire base de données SQLite.
Maintenant, je veux faire la même chose en utilisant EF Core et j’ai jeté un œil à la documentation de journalisation .
Dans mon code de configuration de test, je fais ce que dit la documentation:
using (var db = new BloggingContext())
{
var serviceProvider = db.GetInfrastructure<IServiceProvider>();
var loggerFactory = serviceProvider.GetService<ILoggerFactory>();
loggerFactory.AddProvider(new MyLoggerProvider());
}
Mais je rencontre des problèmes qui, je suppose, résultent des éléments suivants (également de la documentation):
Vous devez uniquement enregistrer le consignateur avec une seule instance de contexte. Une fois que vous l'avez enregistré, il sera utilisé pour toutes les autres instances du contexte dans le même AppDomain.
Les problèmes que je vois dans mes tests indiquent que l'implémentation de mon enregistreur est partagée dans plusieurs contextes (ceci est conforme à la documentation lorsque je les lis). Et comme a) mon lanceur de tests exécute des tests en parallèle et b) que toute ma suite de tests crée des centaines de contextes de base de données - cela ne fonctionne pas très bien.
Question/problèmes:
Appelez la méthode DbContextOptionsBuilder.UseLoggerFactory(loggerFactory)
pour consigner toutes les sorties SQL d'une instance de contexte particulière. Vous pouvez injecter une fabrique d'enregistreurs dans le constructeur du contexte.
Voici un exemple d'utilisation:
//this context writes SQL to any logs and to ReSharper test output window
using (var context = new TestContext(_loggerFactory))
{
var customers = context.Customer.ToList();
}
//this context doesn't
using (var context = new TestContext())
{
var products = context.Product.ToList();
}
Généralement, j'utilise cette fonctionnalité pour les tests manuels. Pour conserver la classe de contexte d'origine propre, un contexte testable dérivé est déclaré avec la méthode OnConfiguring
remplacée:
public class TestContext : FooContext
{
private readonly ILoggerFactory _loggerFactory;
public TestContext() { }
public TestContext(ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseLoggerFactory(_loggerFactory);
}
}
Il suffit de journaliser les requêtes SQL. N'oubliez pas de joindre un enregistreur approprié (comme Console) à loggerFactory
avant de le transmettre au contexte.
Nous pouvons créer un loggerFactory
dans un constructeur de classe de test:
public class TestContext_SmokeTests : BaseTest
{
public TestContext_SmokeTests(ITestOutputHelper output)
: base(output)
{
var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider();
_loggerFactory = serviceProvider.GetService<ILoggerFactory>();
_loggerFactory.AddProvider(new XUnitLoggerProvider(this));
}
private readonly ILoggerFactory _loggerFactory;
}
La classe de test est dérivée de BaseTest
qui supporte l'écriture dans xUnit
sortie:
public interface IWriter
{
void WriteLine(string str);
}
public class BaseTest : IWriter
{
public ITestOutputHelper Output { get; }
public BaseTest(ITestOutputHelper output)
{
Output = output;
}
public void WriteLine(string str)
{
Output.WriteLine(str ?? Environment.NewLine);
}
}
La partie la plus délicate consiste à implémenter un fournisseur de journalisation acceptant IWriter
en tant que paramètre:
public class XUnitLoggerProvider : ILoggerProvider
{
public IWriter Writer { get; private set; }
public XUnitLoggerProvider(IWriter writer)
{
Writer = writer;
}
public void Dispose()
{
}
public ILogger CreateLogger(string categoryName)
{
return new XUnitLogger(Writer);
}
public class XUnitLogger : ILogger
{
public IWriter Writer { get; }
public XUnitLogger(IWriter writer)
{
Writer = writer;
Name = nameof(XUnitLogger);
}
public string Name { get; set; }
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception,
Func<TState, Exception, string> formatter)
{
if (!this.IsEnabled(logLevel))
return;
if (formatter == null)
throw new ArgumentNullException(nameof(formatter));
string message = formatter(state, exception);
if (string.IsNullOrEmpty(message) && exception == null)
return;
string line = $"{logLevel}: {this.Name}: {message}";
Writer.WriteLine(line);
if (exception != null)
Writer.WriteLine(exception.ToString());
}
public bool IsEnabled(LogLevel logLevel)
{
return true;
}
public IDisposable BeginScope<TState>(TState state)
{
return new XUnitScope();
}
}
public class XUnitScope : IDisposable
{
public void Dispose()
{
}
}
}
Nous avons fait ici! Tous les journaux SQL seront affichés dans la fenêtre de sortie du test Rider/Resharper.
Lisez ceci: docs.Microsoft.com/en-us/ef/core/misc Miscellaneous/logging
Il est très important que les applications ne créent pas une nouvelle instance ILoggerFactory pour chaque instance de contexte. Cela entraînerait une fuite de mémoire et des performances médiocres.1
Si vous voulez vous connecter à une destination statique (par exemple, une console), la réponse d'Ilja fonctionne, mais si vous voulez vous connecter d'abord à des tampons personnalisés, chaque fois que dbContext collecte les messages de journal dans son propre tampon (et que vous voudriez faire dans un service multi-utilisateur) , puis UPSSS - Fuites de mémoire (environ 20 Mo par modèle presque vide) ...
Lorsque EF6 disposait d’une solution simple pour s’abonner à un événement de journal sur une ligne, vous pouvez maintenant injecter votre journalisation de la manière suivante:
var messages = new List<string>();
Action<string> verbose = (text) => {
messages.Add(text);
}; // add logging message to buffer
using (var dbContext = new MyDbContext(BuildOptionsBuilder(connectionString, inMemory), verbose))
{
//..
};
vous devriez écrire le monstre de pooling.
P.S. Quelqu'un dit aux architectes d'Ef Core qu'ils ont une mauvaise compréhension de la DI et à ces localisateurs de services sophistiqués qu'ils appellent des "conteneurs" et qu'ils utilisent couramment UseXXX qu'ils empruntent à ASP.Core ne peut pas remplacer "une vulgarité DI du constructeur"! Au moins la fonction de journalisation doit normalement être injectable via le constructeur.
* P.P.S. Lisez aussi ceci https://github.com/aspnet/EntityFrameworkCore/issues/1042 . Cela signifie que l'ajout de LoggerFactory a interrompu l'accès au fournisseur de données InMemory. C'est une fuite d'abstraction telle qu'elle est. EF Core a des problèmes d'architecture.
Code de mise en commun ILoggerFactory:
public class StatefullLoggerFactoryPool
{
public static readonly StatefullLoggerFactoryPool Instance = new StatefullLoggerFactoryPool(()=> new StatefullLoggerFactory());
private readonly Func<StatefullLoggerFactory> construct;
private readonly ConcurrentBag<StatefullLoggerFactory> bag = new ConcurrentBag<StatefullLoggerFactory>();
private StatefullLoggerFactoryPool(Func<StatefullLoggerFactory> construct) =>
this.construct = construct;
public StatefullLoggerFactory Get(Action<string> verbose, LoggerProviderConfiguration loggerProviderConfiguration)
{
if (!bag.TryTake(out StatefullLoggerFactory statefullLoggerFactory))
statefullLoggerFactory = construct();
statefullLoggerFactory.LoggerProvider.Set(verbose, loggerProviderConfiguration);
return statefullLoggerFactory;
}
public void Return(StatefullLoggerFactory statefullLoggerFactory)
{
statefullLoggerFactory.LoggerProvider.Set(null, null);
bag.Add(statefullLoggerFactory);
}
}
public class StatefullLoggerFactory : LoggerFactory
{
public readonly StatefullLoggerProvider LoggerProvider;
internal StatefullLoggerFactory() : this(new StatefullLoggerProvider()){}
private StatefullLoggerFactory(StatefullLoggerProvider loggerProvider) : base(new[] { loggerProvider }) =>
LoggerProvider = loggerProvider;
}
public class StatefullLoggerProvider : ILoggerProvider
{
internal LoggerProviderConfiguration loggerProviderConfiguration;
internal Action<string> verbose;
internal StatefullLoggerProvider() {}
internal void Set(Action<string> verbose, LoggerProviderConfiguration loggerProviderConfiguration)
{
this.verbose = verbose;
this.loggerProviderConfiguration = loggerProviderConfiguration;
}
public ILogger CreateLogger(string categoryName) =>
new Logger(categoryName, this);
void IDisposable.Dispose(){}
}
public class MyDbContext : DbContext
{
readonly Action<DbContextOptionsBuilder> buildOptionsBuilder;
readonly Action<string> verbose;
public MyDbContext(Action<DbContextOptionsBuilder> buildOptionsBuilder, Action<string> verbose=null): base()
{
this.buildOptionsBuilder = buildOptionsBuilder;
this.verbose = verbose;
}
private Action returnLoggerFactory;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (verbose != null)
{
var loggerFactory = StatefullLoggerFactoryPool.Instance.Get(verbose, new LoggerProviderConfiguration { Enabled = true, CommandBuilderOnly = false });
returnLoggerFactory = () => StatefullLoggerFactoryPool.Instance.Return(loggerFactory);
optionsBuilder.UseLoggerFactory(loggerFactory);
}
buildOptionsBuilder(optionsBuilder);
}
// NOTE: not threadsafe way of disposing
public override void Dispose()
{
returnLoggerFactory?.Invoke();
returnLoggerFactory = null;
base.Dispose();
}
}
private static Action<DbContextOptionsBuilder> BuildOptionsBuilder(string connectionString, bool inMemory)
{
return (optionsBuilder) =>
{
if (inMemory)
optionsBuilder.UseInMemoryDatabase(
"EfCore_NETFramework_Sandbox"
);
else
//Assembly.GetAssembly(typeof(Program))
optionsBuilder.UseSqlServer(
connectionString,
sqlServerDbContextOptionsBuilder => sqlServerDbContextOptionsBuilder.MigrationsAssembly("EfCore.NETFramework.Sandbox")
);
};
}
class Logger : ILogger
{
readonly string categoryName;
readonly StatefullLoggerProvider statefullLoggerProvider;
public Logger(string categoryName, StatefullLoggerProvider statefullLoggerProvider)
{
this.categoryName = categoryName;
this.statefullLoggerProvider = statefullLoggerProvider;
}
public IDisposable BeginScope<TState>(TState state) =>
null;
public bool IsEnabled(LogLevel logLevel) =>
statefullLoggerProvider?.verbose != null;
static readonly List<string> events = new List<string> {
"Microsoft.EntityFrameworkCore.Database.Connection.ConnectionClosing",
"Microsoft.EntityFrameworkCore.Database.Connection.ConnectionClosed",
"Microsoft.EntityFrameworkCore.Database.Command.DataReaderDisposing",
"Microsoft.EntityFrameworkCore.Database.Connection.ConnectionOpened",
"Microsoft.EntityFrameworkCore.Database.Connection.ConnectionOpening",
"Microsoft.EntityFrameworkCore.Infrastructure.ServiceProviderCreated",
"Microsoft.EntityFrameworkCore.Infrastructure.ContextInitialized"
};
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
{
if (statefullLoggerProvider?.verbose != null)
{
if (!statefullLoggerProvider.loggerProviderConfiguration.CommandBuilderOnly ||
(statefullLoggerProvider.loggerProviderConfiguration.CommandBuilderOnly && events.Contains(eventId.Name) ))
{
var text = formatter(state, exception);
statefullLoggerProvider.verbose($"MESSAGE; categoryName={categoryName} eventId={eventId} logLevel={logLevel}" + Environment.NewLine + text);
}
}
}
}