This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Thursday, 27 November 2025
Теорія - одна річ, код виробництва - це ще одна річ. У частині 1 ми обговорювали абстракції } Тепер давайте подивимося, як їх застосовують у справжній кодовій базі даних. Ця стаття проходить через справжні фонові служби з цієї платформи блогу: спостерігачі файлів за допомогою правил Polly retrait, заснованих на каналах черг електронної пошти з переривами ланцюгів, семантичні індексатори пошуку з визначенням змін у хешах тощо.
Вхід Частина 1Ми дослідили фундаментальні підходи до впровадження фонових служб у ядрах ASPNET. IHostedService, BackgroundService, управління життєвим циклом, і загальні пастки для того, щоб припинити роботу.
Тепер настав час побачити ці моделі в дії. демонструючи:
Кожний приклад показує практичне розв'язання загальних проблем, з якими ви зустрінетесь, коли будуватимете виробничі послуги.
Перша служба, яку ми розглянемо, стежить за каталогом дописів з блогами markdown і автоматично обробляє їх після зміни. Це чудовий приклад роботи з фоновими записами, що керують подіями.
Після створення або зміни файла з міткою:
Проблема: FileSystemWatcher події можуть розпалюватися під час запису файла, що призводить до IOException коли намагаєшся її прочитати.
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);
}
}
}
}
Зауважте декілька комбінацій ключів:
StartAsync return негайно, процес виконується у AwaitChangesStopAsyncНайцікавішою частиною є те, як ми обробляємо файли, які все ще пишуться. ПолліCity in Alaska USA є бібліотекою з гнучкості NET, яка забезпечує поліс популяцію повторення, перериви електричних схем, тощо:
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);
}
}
Шаблони ключів:
Правила повторення керують типовим випадком, коли текстовий редактор все ще пише файл під час пожежі зміни події.
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
Зареєструвати в Program.cs (всі служби- господаря слідують за цим шаблоном):
builder.Services.AddHostedService<MarkdownDirectoryWatcherService>();
Переглянути повну реалізацію у Mostlylucid/Blog/WatcherService/MarkdownDirectoryWatcherService.cs.
Відсилання пошти - це класичний випадок використання фонових служб. Ви не бажаєте блокувати запит HTTP під час переговорів SMTP, отже ви вставляєте повідомлення у чергу і надсилаєте його у тло.
Після надсилання користувачем коментар або форму контакту:
Ця служба використовує a Channel<T> для черги і Поллі для стійкості:
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();
}
}
Показані шаблони ключів:
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
Це демонструє шаблон виробництва:
Ця служба робить черги аналітичних подій і надсилає їх до Умаміzambia_ districts. kgm Сервер аналітики. просто вогонь і забудь.
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);
}
Різниця ключів від служби електронної пошти:
Це показує, що не всі фонові служби потребують складної роботи з помилками. Для некритичної телеметрії достатньо простої лісозаготівлі.
Тепер давайте розглянемо служби, які виконують періодичну роботу, а не обробляють черги. BrokenLinkCheckerBackgroundService періодично перевіряє наявність посилань у блогі і отримує адреси URL archive.org для пошкоджених адрес.
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");
}
}
}
Зразки продемонстровано:
Індекс семантичного пошуку більш витончений, ніж re-indexes, що фактично змінилися. Це зберігає дорогі вмонтовані виклики API.
csharp
public class SemanticIndexingBackgroundService : BackgroundService
{
private readonly ILogger
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.Plain
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.