NOTE: Apart from
(and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.
Saturday, 14 September 2024
//Less than a minute
Έχω... blogsed πολλά φορές σχετικά με το πώς χρησιμοποιώ Markdown για να δημιουργήσω τις δημοσιεύσεις μου στο blog; Μου αρέσει πραγματικά αυτή η προσέγγιση, αλλά έχει ένα σημαντικό μειονέκτημα - αυτό σημαίνει ότι πρέπει να κάνω έναν πλήρη κύκλο κατασκευής Docker για να ενημερώσετε μια θέση. Αυτό ήταν υπέροχο, ενώ δημιουργούσα περισσότερα χαρακτηριστικά για τα οποία στη συνέχεια μπλόκαρα, αλλά είναι αρκετά περιοριστικό τώρα. Θέλω να είμαι σε θέση να ενημερώσω τις δημοσιεύσεις μου στο blog χωρίς να χρειάζεται να κάνω έναν πλήρη κύκλο κατασκευής. Έτσι τώρα έχω προσθέσει ένα νέο κομμάτι της λειτουργικότητας στο blog μου που μου επιτρέπει να κάνω ακριβώς αυτό.
Με την "υβριδικό" προσέγγιση εννοώ αυτόν τον κύκλο ζωής· και πάλι αρκετά απλοϊκό αλλά κάπως δροσερό (με έναν σούπερ geeky τρόπο!).
Οπότε είναι πολύ απλό.
Αυτό μου επιτρέπει να συνεχίσω να χρησιμοποιώ Rider τοπικά για να δημιουργήσω δημοσιεύσεις blog (στο μέλλον θα μετακινήσω επίσης να επιτρέψει αυτό να συμβεί στην ίδια την ιστοσελίδα), όλες οι μεταφράσεις συμβαίνουν δυναμικά στην ίδια την ιστοσελίδα και μπορώ να ενημερώσω τις δημοσιεύσεις χωρίς να χρειάζεται να κάνουμε έναν πλήρη κύκλο κατασκευής.
Ο κωδικός γι' αυτό είναι YET Aλλο ένα IHostedService
, αυτή τη φορά χρησιμοποιεί το FileSystemWatcher
τάξη για να παρακολουθήσετε έναν κατάλογο για νέα αρχεία. Όταν ένα νέο αρχείο ανιχνεύεται διαβάζει το αρχείο, το επεξεργάζεται και το εισάγει στη βάση δεδομένων. Ή αν διαγράψω μια αγγλική δημοσίευση θα διαγράψει όλες τις μεταφράσεις αυτής της θέσης πάρα πολύ.
Όλος ο κώδικας είναι κάτω, αλλά θα τον σπάσω λίγο εδώ.
using Mostlylucid.Config.Markdown;
using Polly;
using Serilog.Events;
namespace Mostlylucid.Blog.WatcherService;
public class MarkdownDirectoryWatcherService(MarkdownConfig markdownConfig, IServiceScopeFactory serviceScopeFactory,
ILogger<MarkdownDirectoryWatcherService> logger)
: IHostedService
{
private Task _awaitChangeTask = Task.CompletedTask;
private FileSystemWatcher _fileSystemWatcher;
public Task StartAsync(CancellationToken cancellationToken)
{
_fileSystemWatcher = new FileSystemWatcher
{
Path = markdownConfig.MarkdownPath,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime |
NotifyFilters.Size,
Filter = "*.md", // Watch all markdown files
IncludeSubdirectories = true // Enable watching subdirectories
};
// Subscribe to events
_fileSystemWatcher.EnableRaisingEvents = true;
_awaitChangeTask = Task.Run(()=> AwaitChanges(cancellationToken));
logger.LogInformation("Started watching directory {Directory}", markdownConfig.MarkdownPath);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
// Stop watching
_fileSystemWatcher.EnableRaisingEvents = false;
_fileSystemWatcher.Dispose();
Console.WriteLine($"Stopped watching directory: {markdownConfig.MarkdownPath}");
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)
{
OnDeleted(fileEvent);
}
else if (fileEvent.ChangeType == WatcherChangeTypes.Renamed)
{
}
}
}
private async Task OnChangedAsync(WaitForChangedResult e)
{
if (e.Name == null) return;
var activity = Log.Logger.StartActivity("Markdown File Changed {Name}", e.Name);
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);
// Log the retry attempt
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);
var scope = serviceScopeFactory.CreateScope();
var markdownBlogService = scope.ServiceProvider.GetRequiredService<IMarkdownBlogService>();
// Use the Polly retry policy for executing the operation
await retryPolicy.ExecuteAsync(async () =>
{
var blogModel = await markdownBlogService.GetPage(filePath);
activity?.Activity?.SetTag("Page Processed", blogModel.Slug);
blogModel.Language = language;
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
await blogService.SavePost(blogModel);
if (language == MarkdownBaseService.EnglishLanguage)
{
var translateService = scope.ServiceProvider.GetRequiredService<BackgroundTranslateService>();
await translateService.TranslateForAllLanguages(
new PageTranslationModel(){OriginalFileName = filePath, OriginalMarkdown = blogModel.Markdown,Persist = true});
}
activity?.Activity?.SetTag("Page Saved", blogModel.Slug);
});
activity?.Complete();
}
catch (Exception exception)
{
activity?.Complete(LogEventLevel.Error, exception);
}
}
private void OnDeleted(WaitForChangedResult e)
{
if(e.Name == null) return;
var isTranslated = Path.GetFileNameWithoutExtension(e.Name).Contains(".");
var language = MarkdownBaseService.EnglishLanguage;
var slug = Path.GetFileNameWithoutExtension(e.Name);
if (isTranslated)
{
var name = Path.GetFileNameWithoutExtension(e.Name).Split('.');
language = name.Last();
slug = name.First();
}
else
{
var translatedFiles = Directory.GetFiles(markdownConfig.MarkdownTranslatedPath, $"{slug}.*.*");
_fileSystemWatcher.EnableRaisingEvents = false;
foreach (var file in translatedFiles)
{
File.Delete(file);
}
_fileSystemWatcher.EnableRaisingEvents = true;
}
var scope = serviceScopeFactory.CreateScope();
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
blogService.Delete(slug, language);
}
private void OnRenamed(object sender, RenamedEventArgs e)
{
Console.WriteLine($"File renamed: {e.OldFullPath} to {e.FullPath}");
}
}
Έτσι, το μόνο που κάνει είναι να ξεκινήσει ένα νέο έργο που χρησιμοποιεί FileSystemWatcher
σε αυτό apos? S StartAsync
μέθοδος. Σε μια IHostedService
Αυτό είναι το σημείο εισόδου που χρησιμοποιεί το πλαίσιο ASP.NET Core για την έναρξη της υπηρεσίας.
private FileSystemWatcher _fileSystemWatcher;
public Task StartAsync(CancellationToken cancellationToken)
{
_fileSystemWatcher = new FileSystemWatcher
{
Path = markdownConfig.MarkdownPath,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime |
NotifyFilters.Size,
Filter = "*.md", // Watch all markdown files
IncludeSubdirectories = true // Enable watching subdirectories
};
// Subscribe to events
_fileSystemWatcher.EnableRaisingEvents = true;
_awaitChangeTask = Task.Run(()=> AwaitChanges(cancellationToken));
logger.LogInformation("Started watching directory {Directory}", markdownConfig.MarkdownPath);
return Task.CompletedTask;
}
Έχω μια αποστολή για την Υπηρεσία, την οποία πυροβολώ.
private Task _awaitChangeTask = Task.CompletedTask;
_awaitChangeTask = Task.Run(()=> AwaitChanges(cancellationToken));
Αυτό είναι σημαντικό, καθώς διαφορετικά αυτό γίνεται μπλοκάρισμα, θα πρέπει να διασφαλίσετε ότι αυτό λειτουργεί στο παρασκήνιο (ρωτήστε με πώς το γνωρίζω αυτό).
Σε αυτό το σημείο έχουμε δημιουργήσει ένα FileSystemWatcher
για να ακούσετε για τα γεγονότα στον κατάλογο Markdown μου (τυπώθηκε σε έναν κατάλογο ξενιστών για λόγους που θα δούμε αργότερα!)
_fileSystemWatcher = new FileSystemWatcher
{
Path = markdownConfig.MarkdownPath,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime |
NotifyFilters.Size,
Filter = "*.md", // Watch all markdown files
IncludeSubdirectories = true // Enable watching subdirectories
};
// Subscribe to events
_fileSystemWatcher.EnableRaisingEvents = true;
Μπορείς να δεις ότι προσέχω. FileName
, LastWrite
, CreationTime
και Size
αλλαγές. Αυτό συμβαίνει επειδή θέλω να μάθω αν ένα αρχείο δημιουργήθηκε, ενημερώθηκε ή διαγραφεί.
Έχω κι εγώ ένα... Filter
set to *.md
Έτσι, παρακολουθώ μόνο για τα αρχεία markdown και διευκρινίζω ότι θέλω να παρακολουθήσουν subdirectories πάρα πολύ (για τις μεταφράσεις).
Μέσα σε αυτόν τον κώδικα έχω ένα βρόχο αλλαγής που περιμένει για αλλαγές στο σύστημα αρχείων. Σημειώστε ότι μπορείτε επίσης να συνδεθείτε με τα γεγονότα Αλλαγή εδώ, αλλά αυτό μου φάνηκε καθαρότερο.
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)
{
OnDeleted(fileEvent);
}
else if (fileEvent.ChangeType == WatcherChangeTypes.Renamed)
{
}
}
}
Και πάλι αρκετά απλό, το μόνο που κάνει είναι να περιμένει για μια αλλαγή γεγονός και στη συνέχεια καλεί το OnChangedAsync
μέθοδος. Ή εάν το αρχείο έχει διαγραφεί καλεί το OnDeleted
μέθοδος.
Εδώ συμβαίνει η μαγεία. Αυτό που κάνει είναι να ακούει για το γεγονός της αλλαγής. Στη συνέχεια επεξεργάζεται το αρχείο Markdown χρησιμοποιώντας τον αγωγό μου (για να πάρει τις κατηγορίες HTML, την ημερομηνία, τον τίτλο κ.λπ.) και εισάγει αυτό στη βάση δεδομένων. Τότε ανιχνεύει αν το αρχείο είναι στα αγγλικά (έτσι το έγραψα):) και αν είναι αυτό, ξεκινάει μια διαδικασία μετάφρασης. Βλέπεις; this post για το πώς λειτουργεί αυτό.
await retryPolicy.ExecuteAsync(async () =>
{
var blogModel = await markdownBlogService.GetPage(filePath);
activity?.Activity?.SetTag("Page Processed", blogModel.Slug);
blogModel.Language = language;
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
await blogService.SavePost(blogModel);
if (language == MarkdownBaseService.EnglishLanguage)
{
var translateService = scope.ServiceProvider.GetRequiredService<BackgroundTranslateService>();
await translateService.TranslateForAllLanguages(
new PageTranslationModel(){OriginalFileName = filePath, OriginalMarkdown = blogModel.Markdown,Persist = true});
}
activity?.Activity?.SetTag("Page Saved", blogModel.Slug);
});
Σημειώστε ότι χρησιμοποιώ Polly
εδώ για να χειριστεί την επεξεργασία αρχείων.Αυτό εξασφαλίζει ότι το αρχείο έχει πράγματι αναρτηθεί/αποθηκευτεί πριν προσπαθήσει να το επεξεργαστεί.
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);
// Log the retry attempt
logger.LogWarning("File is in use, retrying attempt {RetryCount} after {TimeSpan}", retryCount, timeSpan);
});
Χρησιμοποιώ επίσης SerilogTracing
δραστηριότητες για να με βοηθήσει να βρω τυχόν προβλήματα όταν είναι στην "παραγωγή" πιο εύκολα (I έχουν ένα άρθρο Και σε αυτό επίσης!).
private async Task OnChangedAsync(WaitForChangedResult e)
{
if (e.Name == null) return;
var activity = Log.Logger.StartActivity("Markdown File Changed {Name}", e.Name);
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);
// Log the retry attempt
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);
var scope = serviceScopeFactory.CreateScope();
var markdownBlogService = scope.ServiceProvider.GetRequiredService<IMarkdownBlogService>();
// Use the Polly retry policy for executing the operation
await retryPolicy.ExecuteAsync(async () =>
{
var blogModel = await markdownBlogService.GetPage(filePath);
activity?.Activity?.SetTag("Page Processed", blogModel.Slug);
blogModel.Language = language;
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
await blogService.SavePost(blogModel);
if (language == MarkdownBaseService.EnglishLanguage)
{
var translateService = scope.ServiceProvider.GetRequiredService<BackgroundTranslateService>();
await translateService.TranslateForAllLanguages(
new PageTranslationModel(){OriginalFileName = filePath, OriginalMarkdown = blogModel.Markdown,Persist = true});
}
activity?.Activity?.SetTag("Page Saved", blogModel.Slug);
});
activity?.Complete();
}
catch (Exception exception)
{
activity?.Complete(LogEventLevel.Error, exception);
}
}
Αυτό είναι πραγματικά απλό, απλά ανιχνεύει όταν ένα αρχείο διαγράφεται και το αφαιρεί από τη βάση δεδομένων. Όπως μπορείτε να δείτε το τεστ για να δείτε αν το αρχείο μεταφράζεται και αν είναι αυτό διαγράφει όλες τις μεταφράσεις αυτού του αρχείου.
private void OnDeleted(WaitForChangedResult e)
{
if (e.Name == null) return;
var activity = Log.Logger.StartActivity("Markdown File Deleting {Name}", e.Name);
try
{
var isTranslated = Path.GetFileNameWithoutExtension(e.Name).Contains(".");
var language = MarkdownBaseService.EnglishLanguage;
var slug = Path.GetFileNameWithoutExtension(e.Name);
if (isTranslated)
{
var name = Path.GetFileNameWithoutExtension(e.Name).Split('.');
language = name.Last();
slug = name.First();
}
else
{
var translatedFiles = Directory.GetFiles(markdownConfig.MarkdownTranslatedPath, $"{slug}.*.*");
_fileSystemWatcher.EnableRaisingEvents = false;
foreach (var file in translatedFiles)
{
File.Delete(file);
}
_fileSystemWatcher.EnableRaisingEvents = true;
}
var scope = serviceScopeFactory.CreateScope();
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
blogService.Delete(slug, language);
activity?.Activity?.SetTag("Page Deleted", slug);
activity?.Complete();
logger.LogInformation("Deleted blog post {Slug} in {Language}", slug, language);
}
catch (Exception exception)
{
activity?.Complete(LogEventLevel.Error, exception);
logger.LogError("Error deleting blog post {Slug}", e.Name);
}
}
Χρησιμοποιώ το WinSCP για να ανεβάσω τα αρχεία στην ιστοσελίδα μου. Μπορώ απλά να κάνω ένα "sync" για να ανεβάσω οποιαδήποτε αρχεία markdown τότε η υπηρεσία μου θα τα προσθέσει στην DB. Αυτός είναι ο κατάλογος που ο FileSystemWatcher
Παρακολουθεί. Στο ΜΕΛΛΟΝ θα προσθέσω μια δυνατότητα αποστολής εικόνας σε αυτό επίσης, εκεί θα προσθέσω κάποια προεπεξεργασία και χειρισμό μεγαλύτερων αρχείων.
Έτσι, αυτός είναι ένας πολύ απλός τρόπος για να προσθέσετε νέες δημοσιεύσεις blog στην ιστοσελίδα μου χωρίς να χρειάζεται να κάνουμε έναν πλήρη κύκλο κατασκευής. Σε αυτή τη θέση έδειξα πώς να χρησιμοποιήσετε το FileSystemWatcher
να ανιχνεύσει αλλαγές σε έναν κατάλογο και να τις επεξεργαστεί. Έδειξα επίσης πώς να χρησιμοποιήσετε Polly
να χειριστεί τις επαναλήψεις και Serilog
για την καταγραφή της διαδικασίας.