Back to "Utilisation d'une approche hybride pour blogger"

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

Utilisation d'une approche hybride pour blogger

Saturday, 14 September 2024

Présentation

Je l'ai fait. bloggé de nombreux temps sur la façon dont j'utilise Markdown pour créer mes messages de blog; J'aime vraiment cette approche, mais il a un inconvénient majeur - cela signifie que je dois faire un cycle complet de construction Docker pour mettre à jour un message. C'était FINE alors que je créais plus de fonctionnalités que j'ai ensuite blogé sur, mais c'est assez limitatif maintenant. Je veux être en mesure de mettre à jour mes billets de blog sans avoir à faire un cycle complet de construction. Donc maintenant j'ai ajouté un nouveau peu de fonctionnalité à mon blog qui me permet de faire juste cela.

L'approche hybride

Par approche "hybride", je veux dire ce cycle de vie.

Donc c'est assez simple.

  1. J'écris un nouveau billet de blog dans Markdown, sauvegardez-le sur ma machine locale
  2. le télécharger sur mon site Web.
  3. Un FileWatcher détecte le nouveau fichier et le traite.
  4. Le message est inséré dans la base de données
  5. Les traductions sont lancées.
  6. Une fois les traductions terminées, le message est mis à jour sur le site Web.

Cela me permet de continuer à utiliser Rider localement pour créer des messages de blog (à l'avenir, je vais probablement passer à aussi permettre cela dans le site lui-même), toutes les traductions se produisent dynamiquement sur le site lui-même et je peux mettre à jour des messages sans avoir à faire un cycle de construction complet.

graph TD; A[Write new .md file locally In Rider] --> B[Upload to website using WinSCP]; B --> C[FileWatcher detects new file]; C --> D[Process Markdown file]; D --> E[Insert into Database]; E --> F[Kick off translations to all supported languages]; F-->G[Add Translations to Database]; G-->H[Update Website];

Le Code

Le code pour ceci est YET ANOTHER IHostedService, cette fois il utilise le FileSystemWatcher classe pour regarder un répertoire pour de nouveaux fichiers. Lorsqu'un nouveau fichier est détecté, il lit le fichier, le traite et l'insère dans la base de données. OU si je supprime un message en langue anglaise, il supprimera aussi toutes les traductions de ce message.

Tout le code est en dessous, mais je vais le décomposer un peu ici.

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}");
    }
}
## Démarrage

Donc tout ce qu'il fait est de lancer une nouvelle tâche qui utilise FileSystemWatcher dans elle est StartAsync méthode. Dans un IHostedService C'est le point d'entrée que le framework ASP.NET Core utilise pour démarrer le service.

    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;
    }

J'ai une tâche locale au Service sur laquelle je lance la boucle de changement

private Task _awaitChangeTask = Task.CompletedTask;
 _awaitChangeTask = Task.Run(()=> AwaitChanges(cancellationToken));
   

Ceci est important car sinon cela devient un blocage, vous devez vous assurer que cela fonctionne en arrière-plan (demandez-moi comment je le sais).

Ici, nous avons mis en place un FileSystemWatcher pour écouter les événements dans mon répertoire Markdown (mapé dans un répertoire d'hôte pour des raisons que nous verrons plus tard!)

 _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;

Tu vois que je suis à l'affût. FileName, LastWrite, CreationTime et Size des changements. C'est parce que je veux savoir si un fichier est créé, mis à jour ou supprimé.

J'ai aussi une Filter réglé sur *.md Je ne surveille donc que les fichiers de balisage et je précise que je veux regarder les sous-répertoires aussi (pour les traductions).

La boucle de changement

À l'intérieur de ce code, j'ai une boucle de changement qui attend les changements au système de fichiers. Notez que vous pouvez aussi juste accrocher dans les événements de Change ici, mais ce sentiment plus propre pour moi.

    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)
            {
               
            }
        }
    }

Encore une fois assez simple, tout ce qu'il fait est d'attendre un événement de changement et ensuite appelle le OnChangedAsync méthode. OU si le fichier est supprimé, il appelle le OnDeleted méthode.

SurChangedAsync

C'est là que le'magique' se produit. Ce qu'il fait, c'est écouter l'événement de changement. Il traite ensuite le fichier Markdown en utilisant mon pipeline (pour obtenir les catégories HTML, la date, le titre, etc) et l'indert dans la base de données. Il détecte si le fichier est en anglais (donc je l'ai écrit :)) et s'il est il démarre un processus de traduction. Voir ce poste pour comment ça marche.

   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);
            });

Notez que j'utilise Polly ici pour gérer le traitement de fichier ; cela garantit que le fichier a vraiment été téléchargé / enregistré avant qu'il tente de le traiter.

      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);
                });

J'utilise aussi SerilogTracing des activités pour m'aider à trouver des problèmes quand il est dans la 'production' plus facilement (I avoir un article Sur ça aussi!).............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................

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);
        }
    }
## SurSupprimé

Celui-ci est vraiment simple, il détecte juste quand un fichier est supprimé et le supprime de la base de données; J'utilise MAINTENANT cela comme une mesure de protection idiote que je suis apte à télécharger des articles puis immédiatement voir qu'ils ont des erreurs. Comme vous pouvez le voir, il teste pour voir si le fichier est traduit et s'il supprime toutes les traductions de ce fichier.

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);
        }
    }

Téléchargement des fichiers

J'utilise WinSCP pour télécharger les fichiers sur mon site Web. Je peux simplement faire un "sync" pour télécharger n'importe quel fichier de balisage puis mon service les ajoutera au DB. C'est le répertoire que le FileSystemWatcher est en train de regarder. Dans FUTURE Je vais ajouter une capacité de téléchargement d'image à cela aussi, là je vais ajouter un certain prétraitement et la manipulation de fichiers plus grands.

GagnantSCP

En conclusion

Donc c'est une façon assez simple d'ajouter de nouveaux billets de blog à mon site sans avoir à faire un cycle de construction complet.

logo

©2024 Scott Galloway