Back to "Verwendung eines hybriden Ansatzes zum Bloggen"

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

ASP.NET Markdown

Verwendung eines hybriden Ansatzes zum Bloggen

Saturday, 14 September 2024

Einleitung

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.

Der hybride Ansatz

Mit 'hybrid' Ansatz meine ich diesen Lebenszyklus; wieder ziemlich simplistisch, aber irgendwie cool (auf eine super geeky Weise!)== Einzelnachweise ==

Es ist also ziemlich einfach.

  1. Ich schreibe einen neuen Blog-Post in Markdown, speichern Sie ihn auf meiner lokalen Maschine
  2. Laden Sie es auf meine Website hoch.
  3. Ein FileWatcher erkennt die neue Datei und verarbeitet sie.
  4. Der Beitrag wird in die Datenbank eingefügt
  5. Übersetzungen sind gestartet.
  6. Sobald die Übersetzungen abgeschlossen sind, wird der Beitrag auf der Website aktualisiert.

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.

graph LR; A(Write new .md file)--> B[Upload using WinSCP]; B --> C[New File Detected]; C --> D(Process Markdown); D --> E[Insert into Database]; E --> F(Kick off translations); F-->G[Add Translations to Database]; G-->H[/Update Website/];

Der Code

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.

MarkdownDirectoryWatcherService.cs
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}");
    }
}
## Starten Sie es nach oben

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).

Die Veränderungsschleife

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.

OnChangedAsync

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 ==

flowchart LR A[Start OnChangedAsync] --> B{Is e.Name null} B -- Yes --> C[Return] B -- No --> D[Start Activity and Set File Parameters] D --> E[Execute retryPolicy] E --> F[Process and Save Markdown File] F --> G{Is language English} G -- Yes --> H[Kick off Translation] G -- No --> I[Skip Translation] H --> J[Complete Activity] I --> J J --> K[Handle Errors]
OnChangedAsync
   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);
        }
    }
## Aufgestrichen

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.

flowchart LR A[Start OnDeleted] --> B{Is e.Name null} B -- Yes --> C[Return] B -- No --> D[Start Activity and Set Parameters: isTranslated, language, slug] D --> E{Is file translated} E -- Yes --> F[Set language and slug for translation] E -- No --> G[Delete all translated files] F --> H[Create DI Scope and Get IBlogService] G --> H H --> I[Delete Post from Database] I --> J[Complete Activity] J --> K[Handle Errors if any]
 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);
        }
    }

Hochladen der Dateien

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.

WinSCP

Schlussfolgerung

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.

logo

©2024 Scott Galloway