# Hybridilähestymistavan käyttäminen bloggaamiseen

# Johdanto

Olen pahoillani. [bloggasi monia](/blog/category/Markdown) ajat siitä, kuinka käytän Markdownia blogikirjoitusten tekemiseen; pidän todella tästä lähestymistavasta, mutta sillä on yksi merkittävä haittapuoli - se tarkoittaa, että minun täytyy tehdä täydellinen Docker-rakennesykli päivittääkseni viestin. Tämä oli hauskaa, kun olin luomassa lisää ominaisuuksia, joista sitten bloggasin, mutta se on aika rajoittavaa nyt. Haluan pystyä päivittämään blogikirjoituksiani tarvitsematta tehdä täyttä rakennussykliä.
Joten nyt olen lisännyt blogiini uudenlaista toiminnallisuutta, jonka avulla voin tehdä juuri niin.

<!--category-- ASP.NET, Markdown -->
<datetime class="hidden">2024-09-14T00:30</datetime>

[TOC]

# Hybridilähestymistapa

"Hybridillä" tarkoitan tätä elinkaarta, taas aika yksioikoista mutta tavallaan siistiä (supernörtillä tavalla!).

Se on aika yksinkertaista.

1. Kirjoitan uuden blogikirjoituksen Markdownissa, säästän sen paikalliselle koneelleni
2. Lähetä se nettisivulleni.
3. FileWatcher tunnistaa uuden tiedoston ja käsittelee sen.
4. Viesti syötetään tietokantaan
5. Käännökset polkaistaan käyntiin.
6. Kun käännökset ovat valmiit, viesti päivitetään verkkosivulle.

Näin voin käyttää Rideriä paikallisesti blogikirjoitusten luomiseen (tulevaisuudessa siirryn todennäköisesti myös siihen, että tämä tapahtuu itse sivustolla), kaikki käännökset tapahtuvat dynaamisesti itse sivustolla, ja voin päivittää viestit tekemättä täyttä rakennussykliä.

```mermaid

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

```

# Koodi

Tämän koodi on YET TOISE `IHostedService`, Tällä kertaa se käyttää `FileSystemWatcher` Luokka katselee uusien tiedostojen hakemistoa. Kun uusi tiedosto havaitaan, se lukee tiedoston, käsittelee sen ja syöttää sen tietokantaan. TAI jos poistan englanninkielisen viestin, se poistaa kaikki käännökset myös tästä viestistä.

Koko koodi on alla, mutta murran sen hieman tässä.

<details>
<summary>MarkdownDirectoryWatcherService.cs</summary>

```csharp
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}");
    }
}
```

</details>
## Käynnistetään

Joten se vain aloittaa uuden tehtävän, joka käyttää `FileSystemWatcher` Siinä se on. `StartAsync` menetelmä. • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • > • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • `IHostedService` Tämä on syöttöpiste, jota ASP.NET Core -kehys käyttää palvelun aloittamiseen.

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

Minulla on palvelulle paikallinen tehtävä, jonka käynnistän vaihtosilmukan

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

Tämä on tärkeää, koska muuten tästä tulee este, sinun täytyy varmistaa, että tämä kulkee taustalla (kysy, mistä tiedän tämän).

Täällä olemme perustaneet `FileSystemWatcher` Kuuntele tapahtumia Markdown-hakemistossani (kartoitettu isäntähakemistoon syistä, jotka nähdään myöhemmin!)

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

Huomaat, että katselen `FileName`, `LastWrite`, `CreationTime` sekä `Size` muutokset. Tämä johtuu siitä, että haluan tietää, onko tiedosto luotu, päivitetty vai poistettu.

Minulla on myös `Filter` Aseta `*.md` Joten tarkkailen vain Markdown-tiedostoja ja tarkennan, että haluan katsoa myös alihakemistoja (käännöksiä varten).

## Muutossilmukka

Tämän koodin sisällä on vaihtosilmukka, joka odottaa muutoksia tiedostojärjestelmään.
Huomaa, että voit myös liittyä Change-tapahtumiin täällä, mutta tämä tuntui minusta puhtaammalta.

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

Jälleen melko yksinkertainen, se ei muuta kuin odottaa muutosta tapahtuma ja sitten soittaa `OnChangedAsync` menetelmä. TAI jos tiedosto poistetaan, se kutsuu `OnDeleted` menetelmä.

## Muuttuneessa Asyncissä

Täällä tapahtuu "maagista".
Se kuitenkin kuuntelee muutostapahtumaa. Sen jälkeen se käsittelee Markdown-tiedoston minun putkellani (jossa HTML-luokat, päivämäärä, otsikko jne. saadaan tietokantaan). Se havaitsee sitten, onko tiedosto englanniksi (joten kirjoitin sen :) ja jos on, se käynnistää käännösprosessin. Katso [tämä postaus](/blog/backgroundtranslationspt2) siitä, miten tämä toimii.

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

Huomaa, että käytän `Polly` Tässä käsitellään tiedoston käsittelyä. Näin varmistetaan, että tiedosto on todella ladattu / tallennettu, ennen kuin se yrittää käsitellä sitä.

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

Käytän myös `SerilogTracing` toiminta, joka auttaa minua löytämään ongelmia, kun se on "tuotannossa" helpommin (I [on artikkeli](/blog/selfhostingseqpt2) siitäkin!).

```mermaid
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]

```

<details>
<summary>OnChangedAsync</summary>

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

</details>
## Poistettu

Tämä on todella yksinkertainen, se vain havaitsee, kun tiedosto poistetaan tietokannasta ja poistaa sen tietokannasta. KIINTEÄSTI käytän tätä idioottivarmistuskeinona, kun olen valmis lataamaan artikkeleita ja huomaan heti, että niissä on virheitä. Kuten näet, se testaa, onko tiedosto käännetty ja poistaa kaikki käännökset tiedostosta.

```mermaid
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]

```

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

```

# Tiedostojen lataaminen

Lataan tiedostot verkkosivuilleni WinSCP:n avulla. Voin vain tehdä "syncin" ladata kaikki marketdown-tiedostot, niin palveluni lisää ne DB:hen. Tämä on hakemisto, joka `FileSystemWatcher` on seuraamassa. Tulevaisuudessa lisään kuvanlatauskyvyn tähänkin, lisään siihen esikäsittelyä ja isojen tiedostojen käsittelyä.

![WinSCP](winscp.png)

# Johtopäätöksenä

Joten tämä on aika yksinkertainen tapa lisätä uusia blogikirjoituksia sivustolleni ilman, että tarvitsee tehdä koko rakentaa sykli. Tässä viestissä näytin, miten `FileSystemWatcher` Havaitsemaan muutokset hakemistoon ja käsittelemään niitä. Näytin myös, miten käyttää `Polly` Käsitelläkseen retriisejä ja `Serilog` Kirjata prosessi.