Використовуючи гібридний підхід до блогінгу
Введение
I've написала багато блогів times про те, як я користуюсь Markdown для створення моїх блогових постів; Я дійсно люблю цей підхід, але він має один великий недолік - це означає, що я повинен зробити повний цикл побудови Docker, щоб оновити постM SK2 Це було вправно, коли я створювала більше funkcji, про які я потім написала в блогі, але це ' є досить обмеженим заразMSC4 Я хочу мати можливість оновлювати мої блогові пости без того, щоб робити повний cykl побудowyMska5 Отже, тепер я додав до мого блогу нову функцію, яка дозволяє мені робити саме це.
Гібридний підхід
Я маю на увазі цей життєвий цикл, знову ж таки досить спрощений, але досить крутий.
Тож це досить просто.
- Я напишу новий блог в Markdown, за save it to my local machine
- завантажити його на мій сайт.
- FileWatcher визначає новий файл і обробляє його.
- Пост вбудований у базу даних
- переклади запускаються
- Як тільки переклади закінчуються, пост обновується на сайті.
Це дозволяє мені продовжувати використовувати Rider локально для створення блогових постів. ( в майбутньому я ' скоріш за все перейду до того, щоб дозволити це відбуватися на самому сайті.M SK2 всі переклади відбуваються динамічно на самому місці і я можу оновлювати статті без необхідності робити повний цикл побудови.
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 ANOTHER 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 в ньому 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 щоб послухати події в моєму каталогі Маркдау. ( відображається у домашнім каталогі з тих причин, які ми побачимо пізніше.
_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 метод.
Поміняний асинк
Ось де відбувається 'magicM SK1 Все, що він робить - слухати змінний випадок. Потім обробляє файл Markdown, використовуючи мій pipeline M SK1 щоб отримати HTML категорії , дата , Титул і т.д. МSK4 і вводить це у базу даних M SK5 І тоді визначає, чи файл англійською MSC6 так що я написав його ♫ :)) і якщо це так, то запускає процес перекладу ♫ МSK8 Подивіться цей пост як це працює.
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 тут для обробки файлу; це гарантує, що файл дійсно завантажений M SK1 зачуваний перед тим, як він намагається його обробити.
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 які допомагають мені знаходити будь-які проблеми, коли це 's в МSK1продукціїM SK2 легше мати статтю і на цьому також!).
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);
}
}
Це дуже просте, воно просто розпізнає, коли файл видаляється і видаляє його з бази даних ; Я Здебільшого користуюся цим як ідіотом МSK2 перевіряюче рішення, так як я МСК3 я можу завантажити статті, а потім відразу побачити, що вони мають помилки М СК4 Як ви бачите, це тестує, щоб побачити, чи файл перекладений, і якщо так, то воно видаляє всі переклади того файлу MСК5
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 для завантаження файлів на мою веб-сторінку. Я можу просто зробити M SK1sync' щоб завантажити будь-які відмітки файлів, тоді моя служба додасть їх до DBMSC3 Це каталог, що FileSystemWatcher спостерігає. В Протягом майбутнього часу яM SK1 додам до нього здатність завантажувати зображення, а також , там я МSK3 додаю трохи попереднього обробки та обробки більших файлів.

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