Back to "За допомогою гібридного підходу до блогу"

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

За допомогою гібридного підходу до блогу

Saturday, 14 September 2024

Вступ

I'veve Багато блогів час від часу я використовую Markdown для створення дописів у блогі; мені дійсно подобається цей підхід, але він має один великий недолік - це означає, що мені потрібно зробити повний цикл збирання Docker, щоб оновити допис. Це було добре, поки я створював більше можливостей, про які я згодом писав у блогі, але це досить обмежуючи зараз. Я хочу бути в змозі оновити свої блог-повідомлення без повного збирання циклу. Тепер я додав трохи нових функціональних можливостей до мого блогу, що дозволяє мені зробити саме це.

Гібридний підхід

Під під "гібридним" підходом я маю на увазі цей життєвий цикл, знову досить спрощений, але досить крутий (на супер фантастичний спосіб!).

Це доволі просто.

  1. Я пишу новий допис блогу в Markdown, зберігаю його моєму локальному комп'ютері
  2. Завантажуй на мій веб-сайт.
  3. Інструмент спостереження за файлами визначає новий файл і обробляє його.
  4. Допис буде вставлено у базу даних
  5. Перекладів відривають.
  6. Після завершення перекладу допис оновлюється на веб-сайті.

Це дозволяє мені й надалі використовувати їздця місцево для створення дописів блогу (в майбутньому я, ймовірно, також дозволю, щоб це відбувалося на самому сайті), всі переклади відбуваються динамічно на самому сайті, і я можу оновлювати дописи без повного збирання циклу.

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 ANOI 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 в ньомуDescription of a condition. Do not translate key words (# V1S #, # V1 #,...) StartAsync метод. В IHostedService Це точка, де ядро системи ASP.NET використовує для запуску служби.

    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 встановити до *.md отже, мені слід спостерігати лише за файлами з позначенням, а також вказати, що я хочу спостерігати за підкаталогами (для перекладів).

Змінити цикл

Всередині цього коду знаходиться змінний цикл, який очікує на зміни у файловій системі. Зауважте, що ви також можете просто під'єднатися до подій з зміною, але для мене це було чистіше.

    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, дату, Заголовок і т. д.) до бази даних. Програма DON визначає, чи є файл англійською мовою (отже я написав його:) і якщо це файл, відриває процес перекладу. Див. цей допис як це працює.

   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 Діяльність, яка допомагає мені знайти будь-які проблеми в "продукціях" легше (Я має статтю у цьому теж!).

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

Цей метод дуже простий, він просто виявляє, коли файл видалено і видаляє його з бази даних; я, МАБУТЬ, використовую його, як недоумкуючий захід, який змушує мене вивантажувати статті, і відразу бачу, що вони мають помилки. Як ви можете бачити, програма перевіряє перекладені файли і вилучає всі переклади цього файла.

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, щоб завантажити файли на мій сайт. Я можу просто виконати "синхронізацію," щоб вивантажити всі файли markdown, тоді моя служба додасть ці файли до DB. Це каталог, який FileSystemWatcher переглядає. У FUTURE Я додам можливість вивантаження зображень у це також, там я додам деякі попередньої обробки і обробки великих файлів.

WinSCP

Включення

Це досить простий спосіб додати нові дописи блогу до мого сайту без повного збирання циклу. У цьому дописі я показав, як використовувати FileSystemWatcher для виявлення змін у каталозі і обробки їх. Я також показав, як користуватися Polly працювати з скарбами і Serilog щоб увійти до журналу процесу.

logo

©2024 Scott Galloway