Services de base dans ASP.NET Core - Partie 2: Exemples pratiques
La théorie est une chose; le code de production est un autreM SK1 Dans la partie 1 nous avons couvert les abstractions — maintenant laissez-nous MSC4 voir comment elles M SK5 sont appliquées dans une base de codage réelle . Cet article passe par des services d’arrière-plan réels de cette plate-forme de blog
Introduction
À l'intérieur Partie 1, nous avons étudié les approches fondamentales de la mise en œuvre des services d’information dans l’ASP IHostedService, BackgroundService, gestion du cycle de vie , et les lacunes communes liées à la manutention en cas d’arrêt
Il est maintenant temps de voir ces modèles en action. Dans cet article, nous examinerons les services de base réels d’une plateforme de blog sur la production.
- Surveillants du système de fichiers qui synchronisent les fichiers de marquage à une base de données
- Canal- basé sur les couloirs d’email avec politiques de requête Polly
- Émetteurs d'événements analytiques qui demandent des éléments de fond du lot
- Indexeurs de recherche sémantiques avec détection des changements basés sur hash-
- Contrôles des liens défectueux qui valident périodiquement les URL externes
- Démarrage de la coordination entre les services dépendants
- Portée et cadre d'entité Gestion du cycle de vie
Chaque exemple illustre des solutions pratiques aux problèmes communs auxquels vous serez confrontés'au moment de construire les services de base de production.
Exemple 1: File System Watcher with Polly Retry
Le premier service que nous examinerons, le ', surveille un répertoire des articles de blog marquage et les traite automatiquement lorsqu’ils changent.
Le problème
Quand un fichier de marquage est créé ou modifié:
- Lire le contenu du fichier
- Tracer les métadonnées de marquage (titre,catégoriesM SK2date de publication )
- Rendre à HTML et extraire un texte simple
- Référer à la base de données
- Trigger la traduction vers d'autres langues
- Index de recherche sémantique
Le défi: FileSystemWatcher les événements peuvent se produire alors que le fichier est encore écrit, causant IOException quand vous essayez de le lire.
La solution: MarkdownDirectoryWatcherService
public class MarkdownDirectoryWatcherService(
MarkdownConfig markdownConfig,
IServiceScopeFactory serviceScopeFactory,
IStartupCoordinator startupCoordinator,
ILogger<MarkdownDirectoryWatcherService> logger)
: IHostedService
{
private FileSystemWatcher _fileSystemWatcher;
private Task _awaitChangeTask = Task.CompletedTask;
public Task StartAsync(CancellationToken cancellationToken)
{
_fileSystemWatcher = new FileSystemWatcher
{
Path = markdownConfig.MarkdownPath,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite |
NotifyFilters.CreationTime | NotifyFilters.Size,
Filter = "*.md",
IncludeSubdirectories = true
};
_fileSystemWatcher.EnableRaisingEvents = true;
// Start background processing in a separate task
_awaitChangeTask = Task.Run(() => AwaitChanges(cancellationToken), cancellationToken);
logger.LogInformation("Started watching directory {Directory}", markdownConfig.MarkdownPath);
// Signal ready - watcher is set up and listening
startupCoordinator.SignalReady(StartupServiceNames.MarkdownDirectoryWatcher);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
// Proper cleanup
_fileSystemWatcher.EnableRaisingEvents = false;
_fileSystemWatcher.Dispose();
logger.LogInformation("Stopped watching directory");
return Task.CompletedTask;
}
private async Task AwaitChanges(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var fileEvent = _fileSystemWatcher.WaitForChanged(WatcherChangeTypes.All);
if (fileEvent.ChangeType == WatcherChangeTypes.Changed ||
fileEvent.ChangeType == WatcherChangeTypes.Created)
{
await OnChangedAsync(fileEvent);
}
else if (fileEvent.ChangeType == WatcherChangeTypes.Deleted)
{
await OnDeletedAsync(fileEvent);
}
else if (fileEvent.ChangeType == WatcherChangeTypes.Renamed)
{
await OnRenamedAsync(fileEvent);
}
}
}
}
Notez quelques modèles clés:
- IHostedService, non BackgroundService - Nous avons besoin d’un bon contrôle de la configuration du moniteur
- Répartir les tâches de fond - Le
StartAsyncretourne immédiatement, le traitement se produit dansAwaitChanges - Coordination de démarrage - Signale lorsqu’il est prêt pour des services dépendants
- Élimination appropriée - désactive et débarrasse le moniteur du système de fichiers dans
StopAsync
Traitement des problèmes liés à la fermeture de fichiers avec Polly
La partie la plus intéressante est de savoir comment traiter les fichiers qui sont encore écrits. Polly est une bibliothèque de résilience .NET qui fournit des politiques d'essai de répétition , breakers de circuits
private async Task OnChangedAsync(WaitForChangedResult e)
{
if (e.Name == null) return;
// Serilog activity for distributed tracing
using var activity = Log.Logger.StartActivity("Markdown File Changed {Name}", e.Name);
// Define a retry policy for file access issues
var retryPolicy = Policy
.Handle<IOException>() // Only handle IO exceptions (like file in use)
.WaitAndRetryAsync(5, retryAttempt => TimeSpan.FromMilliseconds(500 * retryAttempt),
(exception, timeSpan, retryCount, context) =>
{
activity?.Activity?.SetTag("Retry Attempt", retryCount);
logger.LogWarning(
"File is in use, retrying attempt {RetryCount} after {TimeSpan}",
retryCount, timeSpan);
});
try
{
var fileName = e.Name;
var isTranslated = Path.GetFileNameWithoutExtension(e.Name).Contains(".");
var language = MarkdownBaseService.EnglishLanguage;
var directory = markdownConfig.MarkdownPath;
if (isTranslated)
{
language = Path.GetFileNameWithoutExtension(e.Name).Split('.').Last();
fileName = Path.GetFileName(fileName);
directory = markdownConfig.MarkdownTranslatedPath;
}
var filePath = Path.Combine(directory, fileName);
using var scope = serviceScopeFactory.CreateScope();
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
// Use the Polly retry policy
await retryPolicy.ExecuteAsync(async () =>
{
// Read the file - might throw IOException if locked
var markdown = await File.ReadAllTextAsync(filePath);
var slug = Path.GetFileNameWithoutExtension(fileName);
if (isTranslated)
{
slug = slug.Split('.').First();
}
// Save to database
var savedModel = await blogService.SavePost(slug, language, markdown);
activity?.Activity?.SetTag("Page Processed", savedModel.Slug);
// Index in semantic search (only for main directory files)
if (!e.Name.Contains(Path.DirectorySeparatorChar))
{
await IndexPostForSemanticSearchAsync(scope, savedModel, language);
}
// Trigger translation for English posts
if (language == MarkdownBaseService.EnglishLanguage &&
!string.IsNullOrEmpty(savedModel.Markdown))
{
var translateService = scope.ServiceProvider
.GetRequiredService<IBackgroundTranslateService>();
await translateService.TranslateForAllLanguages(
new PageTranslationModel
{
OriginalFileName = filePath,
OriginalMarkdown = savedModel.Markdown,
Persist = true
});
}
});
activity?.Complete();
}
catch (Exception exception)
{
activity?.Complete(LogEventLevel.Error, exception);
}
}
Modèles clés ici:
- Politique de réessai Polly - Rétroaction exponentielle 500msM SK2 1s,
- Seuls essayer de nouveau IOException - Autres exceptions émergent
- Services de portée générale - Créer une portée par fichier pour éviter les problèmes liés au cycle de vie
- L'exploitation forestière structurée - Usage Serilog Activités de traçage
- Opérations de cascade - sauvegarder → index → traduire
La politique de réessai traite le cas courant où un éditeur de texte écrit toujours le fichier lorsque l'événement de changement se produit.
Flux visuel
graph TD
A[File Modified] --> B[FileSystemWatcher Event]
B --> C{File Locked?}
C -->|Yes| D[Wait 500ms * Attempt]
D --> E{Retry < 5?}
E -->|Yes| C
E -->|No| F[Log Error]
C -->|No| G[Read File]
G --> H[Parse Markdown]
H --> I[Save to Database]
I --> J{Main Directory?}
J -->|Yes| K[Index for Search]
J -->|No| L{English?}
K --> L
L -->|Yes| M[Trigger Translation]
L -->|No| N[Complete]
M --> N
style A stroke:#059669,stroke-width:3px,color:#10b981
style I stroke:#2563eb,stroke-width:3px,color:#3b82f6
style K stroke:#7c3aed,stroke-width:3px,color:#8b5cf6
style M stroke:#d97706,stroke-width:3px,color:#f59e0b
Enregistrez-le Program.cs (tous les services hébergés suivent ce modèle ):
builder.Services.AddHostedService<MarkdownDirectoryWatcherService>();
Voir la mise en œuvre intégrale ici.
Exemple 2: Channel-Based Email Queue
L’envoi d’un courriel est un usage classique pour les services de base.
Le problème
Lorsqu'un utilisateur soumet un commentaire ou un formulaire de contact:
- Valider et stocker la présentation
- Présentation d'une notification par courriel en rangée
- Réponse de retour immédiate (donM SK1t bloc sur SMTP)
- Envoyer des courriels dans l'arrière-plan avec la logique de réessai
- Gérer gracieusement les défaillances de SMTP (circuit breaker)
La solution: EmailSenderHostedService
Ce service utilise un Channel<T> pour la queue et Polly pour la résilience:
public class EmailSenderHostedService : IEmailSenderHostedService
{
private readonly Channel<BaseEmailModel> _mailMessages =
Channel.CreateUnbounded<BaseEmailModel>();
private readonly CancellationTokenSource _cancellationTokenSource = new();
private Task _sendTask = Task.CompletedTask;
private readonly IEmailService _emailService;
private readonly ILogger<EmailSenderHostedService> _logger;
private readonly IAsyncPolicy _policyWrap;
public EmailSenderHostedService(
IEmailService emailService,
ILogger<EmailSenderHostedService> logger)
{
_emailService = emailService;
_logger = logger;
// Retry policy: 3 attempts with exponential backoff
var retryPolicy = Policy
.Handle<SmtpException>()
.WaitAndRetryAsync(3,
attempt => TimeSpan.FromSeconds(2 * attempt),
(exception, timeSpan, retryCount, context) =>
{
logger.LogWarning(exception,
"Retry {RetryCount} for sending email failed", retryCount);
});
// Circuit breaker: open after 5 failures, stay open for 1 minute
var circuitBreakerPolicy = Policy
.Handle<SmtpException>()
.CircuitBreakerAsync(
5,
TimeSpan.FromMinutes(1),
onBreak: (exception, timespan) =>
{
logger.LogError(
"Circuit broken due to too many failures. Breaking for {BreakDuration}",
timespan);
},
onReset: () =>
{
logger.LogInformation("Circuit reset. Resuming email delivery.");
},
onHalfOpen: () =>
{
logger.LogInformation("Circuit in half-open state. Testing connection...");
});
// Combine retry and circuit breaker
_policyWrap = Policy.WrapAsync(retryPolicy, circuitBreakerPolicy);
}
public async Task SendEmailAsync(BaseEmailModel message)
{
await _mailMessages.Writer.WriteAsync(message);
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting background e-mail delivery");
_sendTask = DeliverAsync(_cancellationTokenSource.Token);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping background e-mail delivery");
// Proper shutdown sequence
await _cancellationTokenSource.CancelAsync();
_mailMessages.Writer.Complete(); // Critical: complete the channel
// Wait for the background task to finish
await Task.WhenAny(_sendTask, Task.Delay(Timeout.Infinite, cancellationToken));
}
private async Task DeliverAsync(CancellationToken token)
{
_logger.LogInformation("E-mail background delivery started");
try
{
// Process items as they arrive
while (await _mailMessages.Reader.WaitToReadAsync(token))
{
BaseEmailModel? message = null;
try
{
message = await _mailMessages.Reader.ReadAsync(token);
// Execute with retry policy and circuit breaker
await _policyWrap.ExecuteAsync(async () =>
{
switch (message)
{
case ContactEmailModel contactEmailModel:
await _emailService.SendContactEmail(contactEmailModel);
break;
case CommentEmailModel commentEmailModel:
await _emailService.SendCommentEmail(commentEmailModel);
break;
case ConfirmEmailModel confirmEmailModel:
await _emailService.SendConfirmationEmail(confirmEmailModel);
break;
}
});
_logger.LogInformation("Email from {SenderEmail} sent", message.SenderEmail);
}
catch (OperationCanceledException)
{
break; // Shutdown requested
}
catch (Exception exc)
{
_logger.LogError(exc,
"Couldn't send an e-mail from {SenderEmail}",
message?.SenderEmail);
}
}
}
catch (OperationCanceledException)
{
_logger.LogWarning("E-mail background delivery canceled");
}
_logger.LogInformation("E-mail background delivery stopped");
}
public void Dispose()
{
_cancellationTokenSource.Dispose();
}
}
Modèles clés démontrés:
- Voie de communication à la queue - Canal sans lien pour les messages
- Établissement des politiques - Essayer à l'intérieur du brise-circuit
- Fermeture gracieuse - Compléter le canalM SK1 annuler le token, attendre la tâche
- Correspondance entre les paramètres - Démarrer différents types de courriels
- Durée de vie étendue - Créé une fois en tant que singulonM SK1 conserve un état de vie long -
Retry and Circuit Breaker Visualised
graph TD
A[Queue Email] --> B[Write to Channel]
B --> C[Background Task Reads]
C --> D{Send Email}
D -->|Success| E[Log Success]
D -->|SmtpException| F{Retry Count < 3?}
F -->|Yes| G[Wait 2s * Attempt]
G --> D
F -->|No| H{Circuit Breaker}
H -->|< 5 Failures| I[Log Failure]
H -->|5+ Failures| J[Open Circuit]
J --> K[Wait 1 Minute]
K --> L[Half-Open: Test]
L -->|Success| M[Close Circuit]
L -->|Failure| J
E --> N[Continue]
I --> N
style A stroke:#059669,stroke-width:3px,color:#10b981
style D stroke:#2563eb,stroke-width:3px,color:#3b82f6
style J stroke:#dc2626,stroke-width:3px,color:#ef4444
style M stroke:#059669,stroke-width:3px,color:#10b981
Cette étude démontre un modèle de production-de classe:
- Procéder de nouveau à la politique gère les défaillances transitoires (problèmes de réseau temporaires)
- Réparateur de circuits (si le SMTP est en baisseM SK1donnez pas de coupe à 'ne continuez pas de la faire
- Voie de communication fournit une pression de recul naturelle (si nous pouvons 'non envoyer, message à la queue
Exemple 3: Émetteur d'événements analytiques (UmamiM SK2
Ce service effectue des queues d'analyse et les envoie à un Umami analytics server. Il' est similaire au service de courriel mais plus simple —aucune politique d'essai n'est nécessaire
public class UmamiBackgroundSender(
IServiceScopeFactory scopeFactory,
ILogger<UmamiBackgroundSender> logger) : IHostedService
{
private readonly CancellationTokenSource _cancellationTokenSource = new();
private readonly Channel<SendBackgroundPayload> _channel =
Channel.CreateUnbounded<SendBackgroundPayload>();
private Task _sendTask = Task.CompletedTask;
public Task StartAsync(CancellationToken cancellationToken)
{
_sendTask = SendRequest(_cancellationTokenSource.Token);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation("UmamiBackgroundSender is stopping.");
// Standard shutdown pattern
await _cancellationTokenSource.CancelAsync();
_channel.Writer.Complete();
try
{
await Task.WhenAny(_sendTask, Task.Delay(Timeout.Infinite, cancellationToken));
}
catch (OperationCanceledException)
{
logger.LogWarning("StopAsync operation was canceled.");
}
}
public async Task TrackPageView(string url, string title, UmamiPayload? payload = null)
{
await using var scope = scopeFactory.CreateAsyncScope();
var payloadService = scope.ServiceProvider.GetRequiredService<PayloadService>();
var sendPayload = payloadService.PopulateFromPayload(payload, null);
sendPayload.Url = url;
sendPayload.Title = title;
await _channel.Writer.WriteAsync(new SendBackgroundPayload("event", sendPayload));
logger.LogInformation("Umami pageview event queued");
}
private async Task SendRequest(CancellationToken token)
{
logger.LogInformation("Umami background delivery started");
// Double while loop: outer waits for items, inner drains all available
while (await _channel.Reader.WaitToReadAsync(token))
{
while (_channel.Reader.TryRead(out var payload))
{
try
{
using var scope = scopeFactory.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<UmamiClient>();
await client.Send(payload.Payload, type: payload.EventType);
logger.LogInformation("Umami background event sent: {EventType}",
payload.EventType);
}
catch (OperationCanceledException)
{
logger.LogWarning("Umami background delivery canceled.");
return;
}
catch (Exception ex)
{
logger.LogError(ex, "Error sending Umami background event.");
}
}
}
}
private record SendBackgroundPayload(string EventType, UmamiPayload Payload);
}
Principales différences entre le service de courriel:
- Aucune politique de résilience - Les analyses ne sont pas critiques, si elles échouent, nous envoyons un message et continuons.
- Double pendant la boucle - La boucle intérieure drainait tous les éléments disponibles pour améliorer l'efficacité
- Création de services étendues - Chaque message reçoit une nouvelle portée pour le client HTTP
Cela démontre que tous les services de base n’ont pas besoin d’une manipulation complexe des erreurs. Pour ceux qui ne le font pas-Télémétrie critiqueM SK2 Une simple saisie pourrait suffire .
Exemple 4: Travail périodique de fond avec BackgroundService
Maintenant, let's examiner les services qui font du travail périodique plutôt que de traiter des queues. BrokenLinkCheckerBackgroundService vérifie périodiquement les liens des articles de blog et reçoit l’archive.org URLs pour ceux qui sont défectueux.
public class BrokenLinkCheckerBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<BrokenLinkCheckerBackgroundService> _logger;
private readonly HttpClient _httpClient;
private readonly TimeSpan _checkInterval = TimeSpan.FromHours(1);
private const int BatchSize = 20;
public BrokenLinkCheckerBackgroundService(
IServiceProvider serviceProvider,
ILogger<BrokenLinkCheckerBackgroundService> logger,
IHttpClientFactory httpClientFactory)
{
_serviceProvider = serviceProvider;
_logger = logger;
_httpClient = httpClientFactory.CreateClient("BrokenLinkChecker");
_httpClient.Timeout = TimeSpan.FromSeconds(30);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Broken Link Checker Background Service started");
// Initial delay to let the application fully start
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await CheckLinksAsync(stoppingToken);
await FetchArchiveUrlsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in Broken Link Checker Background Service");
}
_logger.LogInformation("Broken Link Checker sleeping for {Interval}", _checkInterval);
await Task.Delay(_checkInterval, stoppingToken);
}
_logger.LogInformation("Broken Link Checker Background Service stopped");
}
private async Task CheckLinksAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting link validity check");
using var scope = _serviceProvider.CreateScope();
var brokenLinkService = scope.ServiceProvider.GetRequiredService<IBrokenLinkService>();
var linksToCheck = await brokenLinkService.GetLinksToCheckAsync(BatchSize, cancellationToken);
_logger.LogInformation("Found {Count} links to check", linksToCheck.Count);
foreach (var link in linksToCheck)
{
if (cancellationToken.IsCancellationRequested) break;
try
{
var (statusCode, isBroken, error) = await CheckUrlAsync(link.OriginalUrl, cancellationToken);
await brokenLinkService.UpdateLinkStatusAsync(
link.Id, statusCode, isBroken, error, cancellationToken);
if (isBroken)
{
_logger.LogWarning("Link is broken: {Url} (Status: {StatusCode})",
link.OriginalUrl, statusCode);
}
// Be respectful to servers
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking link: {Url}", link.OriginalUrl);
await brokenLinkService.UpdateLinkStatusAsync(
link.Id, 0, true, ex.Message, cancellationToken);
}
}
}
private async Task<(int statusCode, bool isBroken, string? error)> CheckUrlAsync(
string url,
CancellationToken cancellationToken)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Head, url);
request.Headers.UserAgent.ParseAdd(
"Mozilla/5.0 (compatible; MostlylucidBot/1.0; +https://www.mostlylucid.net)");
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
var statusCode = (int)response.StatusCode;
var isBroken = response.StatusCode == HttpStatusCode.NotFound ||
response.StatusCode == HttpStatusCode.Gone ||
statusCode >= 500;
return (statusCode, isBroken, null);
}
catch (HttpRequestException ex)
{
return (0, true, ex.Message);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
return (0, true, "Request timed out");
}
}
}
Modèles démontrés:
- Contexte Classe de base du service - parfait pour les travaux périodiques
- Délay initial - attendre que d'autres services soient initialisés
- Battage - Processus 20 Liens à la fois
- Limitation du taux - 2-delay de seconde entre les vérifications pour être respectueux
- Création de services étendues - Nouveau champ d’application par lot
- Contrôle du symbole d'annulation - Démarrer tôt si la fermeture est demandée
Exemple 5: Indexage sémantique de la recherche avec HashM SK1 Détection des changements fondés
L'indexateur de recherche sémantique est plus sophistiqué—il n'indexe que les postes qui ont effectivement changé. Cela permet d'économiser des appels API coûteux à embedding.
public class SemanticIndexingBackgroundService : BackgroundService
{
private readonly ILogger<SemanticIndexingBackgroundService> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly ISemanticSearchService _semanticSearchService;
private readonly MarkdownConfig _markdownConfig;
private readonly SemanticSearchConfig _semanticSearchConfig;
private readonly TimeSpan _indexInterval = TimeSpan.FromHours(1);
private readonly TimeSpan _startupDelay = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_semanticSearchConfig.Enabled)
{
_logger.LogInformation("Semantic search is disabled, indexing service will not run");
return;
}
_logger.LogInformation("Semantic indexing background service starting...");
// Wait for other services to initialise
await Task.Delay(_startupDelay, stoppingToken);
// Initialise the semantic search service
try
{
await _semanticSearchService.InitializeAsync(stoppingToken);
_logger.LogInformation("Semantic search initialized successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize semantic search service");
return;
}
// Initial indexing
await IndexAllMarkdownFilesAsync(stoppingToken);
// Periodic re-indexing to catch any changes
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_indexInterval, stoppingToken);
await IndexAllMarkdownFilesAsync(stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during periodic indexing");
}
}
_logger.LogInformation("Semantic indexing background service stopped");
}
private async Task IndexAllMarkdownFilesAsync(CancellationToken stoppingToken)
{
var markdownPath = _markdownConfig.MarkdownPath;
if (!Directory.Exists(markdownPath))
{
_logger.LogWarning("Markdown directory does not exist: {Path}", markdownPath);
return;
}
// Get ONLY files in the main directory, NOT subdirectories
var markdownFiles = Directory.GetFiles(markdownPath, "*.md", SearchOption.TopDirectoryOnly);
_logger.LogInformation("Found {Count} markdown files to index in {Path}",
markdownFiles.Length, markdownPath);
var indexedCount = 0;
var skippedCount = 0;
var errorCount = 0;
using var scope = _serviceProvider.CreateScope();
var markdownRenderingService = scope.ServiceProvider
.GetRequiredService<MarkdownRenderingService>();
foreach (var filePath in markdownFiles)
{
if (stoppingToken.IsCancellationRequested)
break;
try
{
var result = await IndexMarkdownFileAsync(
filePath,
markdownRenderingService,
stoppingToken);
if (result == IndexResult.Indexed)
indexedCount++;
else if (result == IndexResult.Skipped)
skippedCount++;
}
catch (Exception ex)
{
errorCount++;
_logger.LogError(ex, "Error indexing file: {FilePath}", filePath);
}
// Delay to avoid overwhelming the embedding service
await Task.Delay(100, stoppingToken);
}
_logger.LogInformation(
"Indexing complete: {Indexed} indexed, {Skipped} skipped (unchanged), {Errors} errors",
indexedCount, skippedCount, errorCount);
}
private async Task<IndexResult> IndexMarkdownFileAsync(
string filePath,
MarkdownRenderingService markdownRenderingService,
CancellationToken stoppingToken)
{
var fileName = Path.GetFileNameWithoutExtension(filePath);
// Skip translated files (they have language suffix like .es.md, .fr.md)
if (fileName.Contains('.'))
{
var parts = fileName.Split('.');
if (parts.Length >= 2 && parts[^1].Length == 2)
{
return IndexResult.Skipped;
}
}
var markdown = await File.ReadAllTextAsync(filePath, stoppingToken);
var fileInfo = new FileInfo(filePath);
var blogPost = markdownRenderingService.GetPageFromMarkdown(
markdown,
fileInfo.LastWriteTimeUtc,
filePath);
if (blogPost.IsHidden)
{
_logger.LogDebug("Skipping hidden post: {Slug}", blogPost.Slug);
return IndexResult.Skipped;
}
// Compute content hash
var contentHash = ComputeContentHash(blogPost.PlainTextContent);
// Check if reindexing is needed
var needsReindex = await _semanticSearchService.NeedsReindexingAsync(
blogPost.Slug,
MarkdownBaseService.EnglishLanguage,
contentHash,
stoppingToken);
if (!needsReindex)
{
_logger.LogDebug("Skipping unchanged post: {Slug}", blogPost.Slug);
return IndexResult.Skipped;
}
// Create document for indexing
var document = new BlogPostDocument
{
Id = $"{blogPost.Slug}_{MarkdownBaseService.EnglishLanguage}",
Slug = blogPost.Slug,
Title = blogPost.Title,
Content = blogPost.PlainTextContent,
Language = MarkdownBaseService.EnglishLanguage,
Categories = blogPost.Categories.ToList(),
PublishedDate = blogPost.PublishedDate,
ContentHash = contentHash
};
await _semanticSearchService.IndexPostAsync(document, stoppingToken);
_logger.LogInformation("Indexed post: {Slug}", blogPost.Slug);
return IndexResult.Indexed;
}
private static string ComputeContentHash(string content)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(content);
var hashBytes = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hashBytes);
}
private enum IndexResult
{
Indexed,
Skipped
}
}
Modèles clés:
- Configuration-conduite - Ne fonctionne que si la recherche sémantique est activée
- Détection des changements basée sur Hash- - SHAM SK1 abréviation du contenu , subordonner si non modifiée
- Limitation du taux - 100ms de retard entre les opérations d'indexation
- Traitement filtré - Passer les fichiers traduits et les articles cachés
- Mesures détaillées - Indexé de la pisteM SK1dans l'ordre prévu/nombre d'erreurs
Exemple 6: Coordination de démarrage
Le dernier exemple montre comment les services peuvent coordonner leur démarrage. MarkdownReAddPostsService re-procéder tous les fichiers de marquage au démarrage (utilisable après changement du schémaM SK2 mais signale lorsque ' est prêt pour que d'autres services sachent qu'ils peuvent procéder
public class MarkdownReAddPostsService(
MarkdownConfig markdownConfig,
IServiceScopeFactory serviceScopeFactory,
IStartupCoordinator startupCoordinator,
ILogger<MarkdownReAddPostsService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
if (!markdownConfig.ReAddPosts)
{
logger.LogInformation("ReAddPosts is disabled, skipping bulk post re-add");
// Signal ready immediately since we're not doing anything
startupCoordinator.SignalReady(StartupServiceNames.MarkdownReAddPosts);
return;
}
logger.LogWarning("ReAddPosts is ENABLED - re-processing ALL markdown posts on startup");
// Run in background so it doesn't block startup
_ = Task.Run(async () =>
{
try
{
// Small delay to let other services initialize
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
await ReAddAllPostsAsync(cancellationToken);
}
finally
{
// Signal ready when complete (even if there were errors)
startupCoordinator.SignalReady(StartupServiceNames.MarkdownReAddPosts);
}
}, cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task ReAddAllPostsAsync(CancellationToken cancellationToken)
{
using var activity = Log.Logger.StartActivity("ReAdd All Posts");
var stopwatch = Stopwatch.StartNew();
try
{
using var scope = serviceScopeFactory.CreateScope();
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
var markdownBlogService = scope.ServiceProvider.GetRequiredService<IMarkdownBlogService>();
var semanticSearchService = scope.ServiceProvider.GetService<ISemanticSearchService>();
// Process English files
var markdownFiles = Directory.GetFiles(
markdownConfig.MarkdownPath,
"*.md",
SearchOption.TopDirectoryOnly);
logger.LogInformation("Found {Count} English markdown files to process",
markdownFiles.Length);
int processed = 0;
int failed = 0;
foreach (var filePath in markdownFiles)
{
if (cancellationToken.IsCancellationRequested) break;
var slug = Path.GetFileNameWithoutExtension(filePath);
try
{
var blogModel = await markdownBlogService.GetPage(filePath);
blogModel.Language = MarkdownBaseService.EnglishLanguage;
await blogService.SavePost(blogModel);
// Index in semantic search
if (semanticSearchService != null)
{
await IndexPostAsync(semanticSearchService, blogModel,
MarkdownBaseService.EnglishLanguage);
}
processed++;
if (processed % 10 == 0)
{
logger.LogInformation("Processed {Count}/{Total} English posts",
processed, markdownFiles.Length);
}
}
catch (Exception ex)
{
failed++;
logger.LogError(ex, "Failed to process {Slug}", slug);
}
}
// Process translated files
var translatedFiles = Directory.GetFiles(
markdownConfig.MarkdownTranslatedPath,
"*.md",
SearchOption.TopDirectoryOnly);
logger.LogInformation("Found {Count} translated markdown files to process",
translatedFiles.Length);
int translatedProcessed = 0;
int translatedFailed = 0;
foreach (var filePath in translatedFiles)
{
if (cancellationToken.IsCancellationRequested) break;
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
if (!fileNameWithoutExt.Contains(".")) continue;
var parts = fileNameWithoutExt.Split('.');
var slug = parts[0];
var language = parts[^1];
try
{
var blogModel = await markdownBlogService.GetPage(filePath);
blogModel.Language = language;
await blogService.SavePost(blogModel);
if (semanticSearchService != null)
{
await IndexPostAsync(semanticSearchService, blogModel, language);
}
translatedProcessed++;
if (translatedProcessed % 50 == 0)
{
logger.LogInformation("Processed {Count}/{Total} translated posts",
translatedProcessed, translatedFiles.Length);
}
}
catch (Exception ex)
{
translatedFailed++;
logger.LogError(ex, "Failed to process translated post {Slug} ({Language})",
slug, language);
}
}
stopwatch.Stop();
activity?.Activity?.SetTag("English Processed", processed);
activity?.Activity?.SetTag("English Failed", failed);
activity?.Activity?.SetTag("Translated Processed", translatedProcessed);
activity?.Activity?.SetTag("Translated Failed", translatedFailed);
activity?.Activity?.SetTag("Duration", stopwatch.Elapsed.ToString());
activity?.Complete();
logger.LogWarning(
"ReAddPosts completed in {Duration}. English: {EnglishProcessed}/{EnglishTotal}, Translated: {TranslatedProcessed}/{TranslatedTotal}",
stopwatch.Elapsed,
processed, markdownFiles.Length,
translatedProcessed, translatedFiles.Length);
}
catch (Exception ex)
{
activity?.Complete(LogEventLevel.Error, ex);
logger.LogError(ex, "Error during ReAddPosts");
}
}
}
Modèles clés:
- Coordination de démarrage - Signals lorsqu'ils sont prêts pour des services dépendants
- Le feu et oublier avec enfin - Task.Run with finally block ensures signal
- Enregistrement des progrès - Enregistrez tous les 10 Postes en anglais, Tous les ≥50 postes traduits
- Activité structurée - Activité Serilog avec des balises pour l'observabilité
- Traitement en phase 2- - les fichiers en anglais d’abord, puis les documents traduits
Flux de coordination
graph TD
A[App Starts] --> B[MarkdownReAddPostsService.StartAsync]
B --> C{ReAddPosts Enabled?}
C -->|No| D[Signal Ready Immediately]
C -->|Yes| E[Start Background Task]
E --> F[Wait 5 Seconds]
F --> G[Process All English Posts]
G --> H[Process All Translated Posts]
H --> I[Signal Ready in Finally]
D --> J[Other Services Can Start]
I --> J
J --> K[MarkdownDirectoryWatcher Starts]
J --> L[SemanticIndexer Starts]
style A stroke:#059669,stroke-width:3px,color:#10b981
style I stroke:#2563eb,stroke-width:3px,color:#3b82f6
style J stroke:#7c3aed,stroke-width:3px,color:#8b5cf6
Vous remarquerez tous les exemples utilisés ci-dessus IServiceScopeFactory pour créer des sphères de travail. Ce n’est pas optionnel't facultatif —itM SK3s essentiel pour travailler avec Entity Framework et d’autres services à sphère de travail.
Services de base et champ d'application
L’un des aspects les plus difficiles des services de base consiste à gérer la durée de vie du service. Cadre de l'entité. Voici ce que vous avez besoin de savoir
Le problème: Singleton vs Scoped
Services d'information enregistrés auprès de AddHostedService<T>() sont Établissement d'un plan de réglementation communautaire. Ils ' sont créés une fois et vivent pendant toute la durée de vie de l’application. Mais bon nombre des services que vous voudrez utiliser objet d'étude:
DbContext- Portée par défaut et pour une bonne raisonIHttpContextAccessor- Portée à la demande HTTP- Tout service enregistré auprès de
AddScoped<T>()
Si vous essayez d'injecter un service de portée dans une seule tonne, vous obtiendrez cette exception.
Cannot consume scoped service 'YourDbContext' from singleton 'YourBackgroundService'
Pourquoi le contexte de Db est étendu
Cadre d’entité's DbContext identifie les entités et gère les connexions de base de données. Il' est conçu pour être courtM SK2 vivant :
// BAD: DbContext lives forever, accumulates tracked entities,
// connections may timeout, change tracking becomes stale
public class BrokenService : BackgroundService
{
private readonly MyDbContext _dbContext; // Injected once, lives forever
public BrokenService(MyDbContext dbContext)
{
_dbContext = dbContext; // This context will never be disposed!
}
}
Problèmes liés à une Db vivante de longue durée-Contexte d'une Db vivace:
- Les fuites de mémoire - Les entités suivies s'accumulent
- Données inchangées - Change tracker ne voit pas les changements externes
- Problèmes de connexion - Les connexions peuvent être désactivées ou recyclées
- Les erreurs de concurrence - DbContexte n'est pas 't thread-safe
La solution: IServiceScopeFactory
Chaque exemple dans cet article utilise le même modèle—créer un champ d’application pour chaque unité de travail:
public class CorrectService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public CorrectService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory; // This is a singleton, safe to inject
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Create a new scope for each iteration
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
// DbContext is fresh, will be disposed when scope ends
await ProcessWorkAsync(dbContext, stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
// Scope disposed here - DbContext disposed, connections returned to pool
}
}
La granularité de la portée
Combien de fois devez-vous créer un nouveau champ d’application?? Il dépend de votre charge de travail.
Par iteration (la plus communeM SK1
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
// Process batch...
}
Par article (au moment du traitement d'une queueM SK1
await foreach (var item in channel.Reader.ReadAllAsync(stoppingToken))
{
using var scope = _scopeFactory.CreateScope();
// Process single item...
}
Par lot (lorsque vous avez besoin de sémantiques transactionnellesM SK1
var items = await GetBatchAsync(100);
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
await using var transaction = await dbContext.Database.BeginTransactionAsync();
foreach (var item in items)
{
// Process within same transaction...
}
await transaction.CommitAsync();
Champs d'application async
Pour les opérations asynchrones, préférer CreateAsyncScope() qui gère correctement l'élimination asynque:
await using var scope = _scopeFactory.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
await dbContext.SaveChangesAsync(stoppingToken);
// Scope disposed asynchronously
Les erreurs courantes
1. Capture de services à portée générale dans les fermeturesM SK1
// BAD: dbContext captured, will outlive its scope
var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
_ = Task.Run(async () =>
{
await Task.Delay(10000);
await dbContext.SaveChangesAsync(); // Scope already disposed!
});
2. Réutiliser un champ d’application à travers des limites asynciquesM SK1
// BAD: Scope should match unit of work
using var scope = _scopeFactory.CreateScope();
while (!stoppingToken.IsCancellationRequested)
{
var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
await ProcessAsync(dbContext); // Same stale context every iteration
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
3. Forgetting to dispose
// BAD: Scope never disposed, DbContext leaks
var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
await ProcessAsync(dbContext);
// Where's the dispose?
Utiliser toujours using Déclarations ou await using async scopes.
Résumé: Patterns à travers tous les exemples
En regardant tous ces services réels, plusieurs modèles apparaissent.
1. Manipulation correcte du démarrage
Tous les services suivent le même mode de fermeture:
public async Task StopAsync(CancellationToken cancellationToken)
{
// 1. Cancel the cancellation token
await _cancellationTokenSource.CancelAsync();
// 2. Complete any channels
_channel.Writer.Complete();
// 3. Wait for background tasks with timeout
await Task.WhenAny(_backgroundTask, Task.Delay(Timeout.Infinite, cancellationToken));
}
2. Création de services étendues
Les services de base sont des singletons, mais ils ont souvent besoin d’un service de portée (comme DbContexte ). Chaque exemple crée des portéesM SK3
using var scope = _serviceProvider.CreateScope();
var scopedService = scope.ServiceProvider.GetRequiredService<IScopedService>();
3. Patterns de résistance
Les différents services utilisent différentes stratégies de résilience:
- Re-essai de Polly - Vérificateur du système de fichiers manipule les fichiers bloqués
- Écrasateur de circuit Polly - Émetteur de courriel ( évite le marquage par SMTP)
- Limitation du taux - Contrôleur de liens et indexateur être polite à l'égard des services externes
- Détection des changements basée sur Hash- - Indicateur sémantique éviter la réévaluation coûteuse MSC2indexation MSC3
4. Observabilité
Tous les services comprennent un enregistrement complet:
- Démarrage/événements de fermeture - Enregistrement permanent
- Mesures de progrès - Compte des registres de traitement par lots
- Activités de Serilog - Pour le traçage distribué
- Erreur d'enregistrement avec le contexte - Information suffisante pour diagnostiquer les problèmes
5. Configuration-Comportement dirigé
Configuration de vérification des services:
if (!_config.Enabled)
{
_logger.LogInformation("Service is disabled");
return;
}
Cela vous permet d’enabler/disable des fonctionnalités sans changement de code.
Conclusion
Ces exemples réels de ce monde démontrent que les services généraux d’ASP peuvent traiter des scénarios complexes.
- Processus dirigé par Event- avec les observateurs du système de fichiers
- Processus basé sur la cote avec des canaux
- Les tâches périodiques Service d'information
- Opérations résistantes avec les politiques de Polly
- Démarrage coordonné avec des services de coordination personnalisés
- Détection efficace des changements avec le hashing des contenus
Leçons les plus importantes:
- Remplissez toujours les canaux et annulez les tokens dans StopAsync
- Créer des champs d'application pour les services couverts par ces champs
- Choisissez le modèle de résilience approprié pour votre scénario
- Inscrire globalement pour l'observabilité
- Gérer l'opérationCanceledException gracefully
- Utiliser la coordination de démarrage lorsque les services dépendent l'un des autres
Grâce à ces modèles,, vous pouvez construire des services de base de qualité qui sont résistants, observables et faciles à entretenir.