Back to "Χρησιμοποιώντας μια υβριδική προσέγγιση στο blogging"

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

Χρησιμοποιώντας μια υβριδική προσέγγιση στο blogging

Saturday, 14 September 2024

Εισαγωγή

Έχω... blogsed πολλά φορές σχετικά με το πώς χρησιμοποιώ Markdown για να δημιουργήσω τις δημοσιεύσεις μου στο blog; Μου αρέσει πραγματικά αυτή η προσέγγιση, αλλά έχει ένα σημαντικό μειονέκτημα - αυτό σημαίνει ότι πρέπει να κάνω έναν πλήρη κύκλο κατασκευής Docker για να ενημερώσετε μια θέση. Αυτό ήταν υπέροχο, ενώ δημιουργούσα περισσότερα χαρακτηριστικά για τα οποία στη συνέχεια μπλόκαρα, αλλά είναι αρκετά περιοριστικό τώρα. Θέλω να είμαι σε θέση να ενημερώσω τις δημοσιεύσεις μου στο blog χωρίς να χρειάζεται να κάνω έναν πλήρη κύκλο κατασκευής. Έτσι τώρα έχω προσθέσει ένα νέο κομμάτι της λειτουργικότητας στο blog μου που μου επιτρέπει να κάνω ακριβώς αυτό.

Η Υβριδική Προσέγγιση

Με την "υβριδικό" προσέγγιση εννοώ αυτόν τον κύκλο ζωής· και πάλι αρκετά απλοϊκό αλλά κάπως δροσερό (με έναν σούπερ geeky τρόπο!).

Οπότε είναι πολύ απλό.

  1. Γράφω ένα νέο blog στο Markdown, το φυλάω στο τοπικό μου μηχάνημα
  2. Ανέβασέ το στην ιστοσελίδα μου.
  3. Ένα αρχείοWatcher ανιχνεύει το νέο αρχείο και το επεξεργάζεται.
  4. Η δημοσίευση εισάγεται στη βάση δεδομένων
  5. Οι μεταφράσεις ξεκινούν.
  6. Μόλις ολοκληρωθούν οι μεταφράσεις, η δημοσίευση ενημερώνεται στην ιστοσελίδα.

Αυτό μου επιτρέπει να συνεχίσω να χρησιμοποιώ Rider τοπικά για να δημιουργήσω δημοσιεύσεις blog (στο μέλλον θα μετακινήσω επίσης να επιτρέψει αυτό να συμβεί στην ίδια την ιστοσελίδα), όλες οι μεταφράσεις συμβαίνουν δυναμικά στην ίδια την ιστοσελίδα και μπορώ να ενημερώσω τις δημοσιεύσεις χωρίς να χρειάζεται να κάνουμε έναν πλήρη κύκλο κατασκευής.

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

Ο κώδικας

Ο κωδικός γι' αυτό είναι YET Aλλο ένα IHostedService, αυτή τη φορά χρησιμοποιεί το FileSystemWatcher τάξη για να παρακολουθήσετε έναν κατάλογο για νέα αρχεία. Όταν ένα νέο αρχείο ανιχνεύεται διαβάζει το αρχείο, το επεξεργάζεται και το εισάγει στη βάση δεδομένων. Ή αν διαγράψω μια αγγλική δημοσίευση θα διαγράψει όλες τις μεταφράσεις αυτής της θέσης πάρα πολύ.

Όλος ο κώδικας είναι κάτω, αλλά θα τον σπάσω λίγο εδώ.

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}");
    }
}
## Ξεκίνησέ το.

Έτσι, το μόνο που κάνει είναι να ξεκινήσει ένα νέο έργο που χρησιμοποιεί 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 πάρα πολύ (για τις μεταφράσεις).

Το Change Loop

Μέσα σε αυτόν τον κώδικα έχω ένα βρόχο αλλαγής που περιμένει για αλλαγές στο σύστημα αρχείων. Σημειώστε ότι μπορείτε επίσης να συνδεθείτε με τα γεγονότα Αλλαγή εδώ, αλλά αυτό μου φάνηκε καθαρότερο.

    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 μέθοδος.

OnChangedAsync

Εδώ συμβαίνει η μαγεία. Αυτό που κάνει είναι να ακούει για το γεγονός της αλλαγής. Στη συνέχεια επεξεργάζεται το αρχείο 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 έχουν ένα άρθρο Και σε αυτό επίσης!).

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

Αυτό είναι πραγματικά απλό, απλά ανιχνεύει όταν ένα αρχείο διαγράφεται και το αφαιρεί από τη βάση δεδομένων. Όπως μπορείτε να δείτε το τεστ για να δείτε αν το αρχείο μεταφράζεται και αν είναι αυτό διαγράφει όλες τις μεταφράσεις αυτού του αρχείου.

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

Ανεβάζοντας τα Αρχεία

Χρησιμοποιώ το WinSCP για να ανεβάσω τα αρχεία στην ιστοσελίδα μου. Μπορώ απλά να κάνω ένα "sync" για να ανεβάσω οποιαδήποτε αρχεία markdown τότε η υπηρεσία μου θα τα προσθέσει στην DB. Αυτός είναι ο κατάλογος που ο FileSystemWatcher Παρακολουθεί. Στο ΜΕΛΛΟΝ θα προσθέσω μια δυνατότητα αποστολής εικόνας σε αυτό επίσης, εκεί θα προσθέσω κάποια προεπεξεργασία και χειρισμό μεγαλύτερων αρχείων.

WinSCP

Συμπέρασμα

Έτσι, αυτός είναι ένας πολύ απλός τρόπος για να προσθέσετε νέες δημοσιεύσεις blog στην ιστοσελίδα μου χωρίς να χρειάζεται να κάνουμε έναν πλήρη κύκλο κατασκευής. Σε αυτή τη θέση έδειξα πώς να χρησιμοποιήσετε το FileSystemWatcher να ανιχνεύσει αλλαγές σε έναν κατάλογο και να τις επεξεργαστεί. Έδειξα επίσης πώς να χρησιμοποιήσετε Polly να χειριστεί τις επαναλήψεις και Serilog για την καταγραφή της διαδικασίας.

logo

©2024 Scott Galloway