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
//10 minute read
Ich habe viele Blogs mal darüber, wie ich Markdown verwenden, um meine Blog-Posts zu erstellen; Ich mag diesen Ansatz, aber es hat einen großen Nachteil - es bedeutet, dass ich einen kompletten Docker bauen Zyklus zu tun, um einen Beitrag zu aktualisieren. Das war FINE, während ich mehr Funktionen erstellte, über die ich dann bloggte, aber es ist jetzt ziemlich begrenzt. Ich möchte in der Lage sein, meine Blog-Posts zu aktualisieren, ohne einen kompletten Bauzyklus zu machen. Jetzt habe ich meinem Blog eine neue Funktionalität hinzugefügt, die es mir erlaubt, genau das zu tun.
Mit 'hybrid' Ansatz meine ich diesen Lebenszyklus; wieder ziemlich simplistisch, aber irgendwie cool (auf eine super geeky Weise!)== Einzelnachweise ==
Es ist also ziemlich einfach.
Dies ermöglicht es mir, weiterhin mit Rider vor Ort Blog-Posts zu erstellen (in Zukunft werde ich wahrscheinlich zu bewegen, um auch dies in der Website selbst geschehen), alle Übersetzungen dynamisch auf der Website selbst passieren und ich kann Beiträge aktualisieren, ohne einen vollständigen Aufbau Zyklus zu tun.
Der Code dafür ist YET ANOTHER IHostedService, dieses Mal nutzt es die FileSystemWatcher Klasse, um ein Verzeichnis für neue Dateien zu sehen. Wenn eine neue Datei erkannt wird, liest sie die Datei, verarbeitet sie und fügt sie in die Datenbank ein. ODER wenn ich einen englischen Post lösche, wird er auch alle Übersetzungen dieses Posts löschen.
Der ganze Code ist unten, aber ich breche ihn hier ein bisschen auf.
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}");
    }
}
Alles, was es tut, ist, eine neue Aufgabe zu starten, die FileSystemWatcher in der es ist StartAsync verfahren. In einem IHostedService Dies ist der Einstiegspunkt, mit dem das ASP.NET Core Framework den Dienst startet.
    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;
    }
Ich habe eine Aufgabe vor Ort, um den Dienst, die ich feuern die Änderung Schleife auf
private Task _awaitChangeTask = Task.CompletedTask;
 _awaitChangeTask = Task.Run(()=> AwaitChanges(cancellationToken));
   
Dies ist wichtig, da sonst dies blockiert wird, müssen Sie sicherstellen, dass dies im Hintergrund läuft (Fragen Sie mich, wie ich dies weiß).
Hier haben wir eine FileSystemWatcher um Ereignisse in meinem Markdown-Verzeichnis zu hören (aus Gründen, die wir später sehen werden, in ein Host-Verzeichnis aufgenommen!)
 _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;
Du kannst sehen, dass ich aufpasse. FileName, LastWrite, CreationTime und Size Veränderungen. Dies liegt daran, dass ich wissen möchte, ob eine Datei erstellt, aktualisiert oder gelöscht wird.
Ich habe auch eine Filter eingestellt auf *.md also schaue ich nur auf Markdown-Dateien und spezifizieren möchte ich auch Unterverzeichnisse sehen (für die Übersetzungen).
In diesem Code habe ich eine Änderungsschleife, die auf Änderungen am Dateisystem wartet. Beachten Sie, Sie können auch einfach in die Change-Ereignisse hier haken, aber das fühlte sich für mich sauberer an.
    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)
            {
               
            }
        }
    }
Wieder ziemlich einfach, alles, was es tut, ist auf ein Wechselereignis warten und dann ruft die OnChangedAsync verfahren. ODER wenn die Datei gelöscht wird, ruft sie die OnDeleted verfahren.
Hier passiert das 'Magische'. Was es tut, ist das Wechselereignis zu hören. Es verarbeitet dann die Markdown-Datei mit meiner Pipeline (um die HTML-Kategorien, Datum, Titel usw. zu erhalten) und indert dies in die Datenbank ein. Es erkennt, ob die Datei in Englisch ist (so schrieb ich sie :)) und wenn es ist, startet es einen Übersetzungsprozess. Siehe Dieser Beitrag für die Art und Weise, wie das funktioniert.
   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);
            });
Beachten Sie, dass ich Polly hier, um die Dateiverarbeitung zu handhaben; dies stellt sicher, dass die Datei WIRKLICH hochgeladen / gespeichert wurde, bevor sie versucht, sie zu verarbeiten.
      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);
                });
Ich verwende auch SerilogTracing Aktivitäten, die mir helfen, Probleme zu finden, wenn es in 'Produktion' leichter ist (I einen Artikel haben Auch darauf!)== Einzelnachweise ==
   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);
        }
    }
Dieses ist wirklich einfach, es erkennt nur, wenn eine Datei gelöscht wird und entfernt sie aus der Datenbank; I MAINLY verwenden Sie dies als idiot-proofing Maßnahme, da ich geeignet bin, Artikel hochladen dann sofort sehen, dass sie Fehler haben. Wie Sie sehen können, testet sie, um zu sehen, ob die Datei übersetzt ist und ob sie alle Übersetzungen dieser Datei löscht.
 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);
        }
    }
Ich benutze WinSCP, um die Dateien auf meine Website hochzuladen. Ich kann einfach einen 'Sync' tun, um alle Markdown-Dateien hochzuladen, dann wird mein Dienst sie zur DB hinzufügen. Dies ist das Verzeichnis, das die FileSystemWatcher beobachtet. In FUTURE werde ich auch dazu eine Bild-Upload-Fähigkeit hinzufügen, dort werde ich einige Vorverarbeitung und Handhabung von größeren Dateien hinzufügen.

So ist dies eine ziemlich einfache Weise, neue Blog-Posts zu meiner Website hinzuzufügen, ohne einen vollen Bauzyklus zu tun. In diesem Beitrag zeigte ich, wie man die FileSystemWatcher um Änderungen in einem Verzeichnis zu erkennen und zu verarbeiten. Ich habe auch gezeigt, wie man Polly für den Umgang mit Retries und Serilog um den Prozess zu protokollieren.