Aufbau eines Remote Markdown Fetchers für Markdig (Deutsch (German))

Aufbau eines Remote Markdown Fetchers für Markdig

Friday, 07 November 2025

//

33 minute read

Einleitung

Eine der Herausforderungen, vor denen ich beim Aufbau dieses Blogs stand, war, wie man effizient externe Markdown-Inhalte ohne manuelles Kopieren und Einfügen überall einschließt.

Ich wollte README-Dateien aus meinen GitHub-Repositories holen, Dokumentationen aus anderen Projekten enthalten und alles automatisch synchronisiert halten.Mostlylucid.Markdig.FetchExtensionDie Lösung?

Eine benutzerdefinierte Markdig-Erweiterung, die Remote-Markdown zur Renderzeit holt und sie intelligent zwischenspeichert.

In diesem Beitrag begleite ich dich, wie ich gebaut habe.

**- eine Komplettlösung zum Abrufen und Caching von Remote-Markdown-Inhalten mit Unterstützung für mehrere Speicher-Backends, automatisches Polling und einem veralteten Caching-Muster.**HINWEIS: Das ist immer noch Vorveröffentlichung, aber ich wollte es da raus bekommen.disable="true"Viel Spaß, aber es könnte noch nicht funktionieren.

**Dieser Artikel ist KI generiert - mit claude Code, der auch mir half, die Funktion zu bauen.**AKTUELLE ENTWICKLUNG[TOC]: Hinzugefügt

Parameter so können wir nun die Tags richtig vorführen, ohne dass sie verarbeitet werdenUPDATE (7. November 2025).

: Inhaltsverzeichnis (TOC)-Generierung hinzugefügt! Verwendung

in Ihrem Markdown, um automatisch ein anklickbares Inhaltsverzeichnis aus Dokumentüberschriften zu generieren.

Sehen Sie die Quelle dafür hier

  1. auf der GitHub für diese SeiteNuGet
  2. NuGetWarum das bauen?
  3. **Bevor ich in die technischen Details eintauche, möchte ich das Problem erklären.**Ich habe mehrere Szenarien, in denen ich externe Markdown-Inhalte hinzufügen muss:
  4. README-Pakete: Wenn ich über ein NuGet-Paket schreibe, das ich veröffentlicht habe, möchte ich seine README direkt von GitHub enthalten

API-Dokumentation

  • : Externe API-Dokumente, die sich häufig ändern müssen, um synchron zu bleiben
  • Gemeinsame Inhalte
  • : Dokumentation, die in einem Repository lebt, aber an mehreren Orten erscheinen muss
  • Leistung

: Ich möchte diesen Inhalt nicht auf jeder Seite laden - das wäre langsam und verschwenderisch

Der naive Ansatz wäre, einen HTTP-Client zu verwenden, um Markdown zu holen, wann immer Sie ihn brauchen.

Aber das ist problematisch:

graph TD
    A[Markdown with fetch tags] --> B[MarkdownFetchPreprocessor]
    B --> C{Check Cache}
    C -->|Fresh| D[Return Cached Content]
    C -->|Stale/Missing| E[Fetch from Remote URL]
    E -->|Success| F[Update Cache]
    E -->|Failure| G{Has Cached?}
    G -->|Yes| H[Return Stale Cache]
    G -->|No| I[Return Error Comment]
    F --> J[Replace fetch with Content]
    D --> J
    H --> J
    I --> J
    J --> K[Processed Markdown]
    K --> L[Your Markdig Pipeline]
    L --> M[Final HTML]

Jede Anfrage trifft den entfernten ServerNetzwerk Latenz Auswirkungen Seite LadezeitenKeine Offline-Unterstützung

  1. Kein Umgang mit vorübergehenden Ausfällen<fetch>Ich brauchte etwas Klügeres: Einmal holen, intelligent zwischenspeichern, automatisch aktualisieren und Fehler anmutig handhaben.
  2. Überblick über die Architektur
  3. Die Erweiterung folgt einem Vorverarbeitungsansatz, anstatt Teil der Markdig-Parsing-Pipeline zu sein.
  4. Dies ist entscheidend, weil es bedeutet, gesammelte Inhalte fließt durch Ihre gesamte Markdig-Pipeline, immer alle Ihre kundenspezifischen Erweiterungen, Syntax Hervorhebung und Styling.

Die wichtigste Einsicht hier ist:

Vorverarbeitung

# My Documentation

<!-- Failed to fetch content from https://raw.githubusercontent.com/user/repo/main/README.md: HTTP 404: Not Found -->

Bevor Ihr Abschlag auf die Markdig-Pipeline trifft, haben wir:

  • Suchen nach
  • Tags
  • Lösen Sie den Inhalt (aus Cache oder Remote)
  • Ersetzen Sie die Tags durch tatsächliche Markierung

Dann lass Markdig alles zusammen verarbeiten

Dies sorgt für Konsistenz - alle Markdown bekommt die gleiche Behandlung unabhängig von ihrer Quelle.Die grundlegende SyntaxDie Verwendung der Erweiterung ist einfach.

graph LR
    A[IMarkdownFetchService Interface] --> B[InMemoryMarkdownFetchService]
    A --> C[FileBasedMarkdownFetchService]
    A --> D[PostgresMarkdownFetchService]
    A --> E[SqliteMarkdownFetchService]
    A --> F[SqlServerMarkdownFetchService]
    A --> G[YourCustomService]

    B --> H[ConcurrentDictionary]
    C --> I[File System + SemaphoreSlim]
    D --> J[PostgreSQL Database]
    E --> K[SQLite Database]
    F --> L[SQL Server Database]
    G --> M[Your Storage Backend]

In deinem Abspann:

Das war's!IMarkdownFetchService:

public interface IMarkdownFetchService
{
    Task<MarkdownFetchResult> FetchMarkdownAsync(
        string url,
        int pollFrequencyHours,
        int blogPostId = 0);

    Task<bool> RemoveCachedMarkdownAsync(
        string url,
        int blogPostId = 0);
}

Die Erweiterung wird

Holen Sie die README von GitHub

Halten Sie es 24 Stunden lang fest.ConcurrentDictionary:

public class InMemoryMarkdownFetchService : IMarkdownFetchService
{
    private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly ILogger<InMemoryMarkdownFetchService> _logger;

    public async Task<MarkdownFetchResult> FetchMarkdownAsync(
        string url,
        int pollFrequencyHours,
        int blogPostId)
    {
        var cacheKey = GetCacheKey(url, blogPostId);

        // Check cache
        if (_cache.TryGetValue(cacheKey, out var cached))
        {
            var age = DateTimeOffset.UtcNow - cached.FetchedAt;
            if (age.TotalHours < pollFrequencyHours)
            {
                _logger.LogDebug("Returning cached content for {Url}", url);
                return new MarkdownFetchResult
                {
                    Success = true,
                    Content = cached.Content
                };
            }
        }

        // Fetch fresh content
        var fetchResult = await FetchFromUrlAsync(url);

        if (fetchResult.Success)
        {
            _cache[cacheKey] = new CacheEntry
            {
                Content = fetchResult.Content,
                FetchedAt = DateTimeOffset.UtcNow
            };
        }
        else if (cached != null)
        {
            // Fetch failed, return stale cache
            _logger.LogWarning("Fetch failed, returning stale cache for {Url}", url);
            return new MarkdownFetchResult
            {
                Success = true,
                Content = cached.Content
            };
        }

        return fetchResult;
    }

    private static string GetCacheKey(string url, int blogPostId)
        => $"{url}_{blogPostId}";
}

Rückgabe zwischengespeicherter Inhalte bei nachfolgenden Anfragen

  1. Automatisch aktualisieren, wenn der Cache abläuft
  2. Architektur des Storage Providers
  3. Eines der Design-Prinzipien, denen ich folgte, war
  4. Flexibilität
  5. Unterschiedliche Anwendungen haben unterschiedliche Bedürfnisse.
  6. Eine kleine Demo-App braucht PostgreSQL nicht, aber eine Multiserver-Produktions-Deployment tut es.

Also baute ich eine steckbare Speicherarchitektur:Die KernschnittstelleAlles implementiert

Einfach und sauber.

Jede Implementierung übernimmt die Speicherung auf eigene Weise, aber die Schnittstelle bleibt konstant.

public class FileBasedMarkdownFetchService : IMarkdownFetchService
{
    private readonly string _cacheDirectory;
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly ILogger<FileBasedMarkdownFetchService> _logger;
    private readonly SemaphoreSlim _fileLock = new(1, 1);

    public async Task<MarkdownFetchResult> FetchMarkdownAsync(
        string url,
        int pollFrequencyHours,
        int blogPostId)
    {
        var cacheKey = ComputeCacheKey(url, blogPostId);
        var cacheFile = GetCacheFilePath(cacheKey);

        await _fileLock.WaitAsync();
        try
        {
            // Check if file exists and is fresh
            if (File.Exists(cacheFile))
            {
                var fileInfo = new FileInfo(cacheFile);
                var age = DateTimeOffset.UtcNow - fileInfo.LastWriteTimeUtc;

                if (age.TotalHours < pollFrequencyHours)
                {
                    var cached = await File.ReadAllTextAsync(cacheFile);
                    return new MarkdownFetchResult
                    {
                        Success = true,
                        Content = cached
                    };
                }
            }

            // Fetch fresh
            var fetchResult = await FetchFromUrlAsync(url);

            if (fetchResult.Success)
            {
                await File.WriteAllTextAsync(cacheFile, fetchResult.Content);
            }
            else if (File.Exists(cacheFile))
            {
                // Return stale on fetch failure
                var stale = await File.ReadAllTextAsync(cacheFile);
                return new MarkdownFetchResult
                {
                    Success = true,
                    Content = stale
                };
            }

            return fetchResult;
        }
        finally
        {
            _fileLock.Release();
        }
    }

    private string GetCacheFilePath(string cacheKey)
        => Path.Combine(_cacheDirectory, $"{cacheKey}.md");

    private static string ComputeCacheKey(string url, int blogPostId)
    {
        var combined = $"{url}_{blogPostId}";
        using var sha256 = SHA256.Create();
        var bytes = Encoding.UTF8.GetBytes(combined);
        var hash = sha256.ComputeHash(bytes);
        return Convert.ToHexString(hash);
    }
}

In-Memory Storage: Perfekt für Demos

  1. Die einfachste Implementierung nutztSemaphoreSlimWie Sie sehen können, macht dies folgendes:
  2. Erstellt einen Cache-Schlüssel aus der URL und Blog-Post-ID
  3. Prüft, ob wir Inhalte zwischengespeichert haben und ob es frisch ist
  4. Wenn der Cache frisch ist, gibt er sofort zurück
  5. Versucht, frischen Inhalt zu holen.

Beim Erfolg aktualisiert der Cache

Beim Ausfall mit zwischengespeicherten Inhalten, gibt der veraltete Cache zurück (stale-wile-revalidate!)

public class PostgresMarkdownFetchService : IMarkdownFetchService
{
    private readonly MarkdownCacheDbContext _dbContext;
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly ILogger<PostgresMarkdownFetchService> _logger;

    public async Task<MarkdownFetchResult> FetchMarkdownAsync(
        string url,
        int pollFrequencyHours,
        int blogPostId)
    {
        var cacheKey = GetCacheKey(url, blogPostId);

        // Query cache
        var cached = await _dbContext.MarkdownCache
            .FirstOrDefaultAsync(c => c.CacheKey == cacheKey);

        if (cached != null)
        {
            var age = DateTimeOffset.UtcNow - cached.LastFetchedAt;
            if (age.TotalHours < pollFrequencyHours)
            {
                return new MarkdownFetchResult
                {
                    Success = true,
                    Content = cached.Content
                };
            }
        }

        // Fetch fresh
        var fetchResult = await FetchFromUrlAsync(url);

        if (fetchResult.Success)
        {
            if (cached == null)
            {
                cached = new MarkdownCacheEntry
                {
                    CacheKey = cacheKey,
                    Url = url,
                    BlogPostId = blogPostId
                };
                _dbContext.MarkdownCache.Add(cached);
            }

            cached.Content = fetchResult.Content;
            cached.LastFetchedAt = DateTimeOffset.UtcNow;
            await _dbContext.SaveChangesAsync();
        }
        else if (cached != null)
        {
            // Return stale
            return new MarkdownFetchResult
            {
                Success = true,
                Content = cached.Content
            };
        }

        return fetchResult;
    }
}

Bei Ausfall ohne Cache, gibt Fehler zurück

CREATE TABLE markdown_cache (
    id SERIAL PRIMARY KEY,
    cache_key VARCHAR(128) NOT NULL UNIQUE,
    url VARCHAR(2048) NOT NULL,
    blog_post_id INTEGER NOT NULL,
    content TEXT NOT NULL,
    last_fetched_at TIMESTAMP WITH TIME ZONE NOT NULL,
    CONSTRAINT ix_markdown_cache_cache_key UNIQUE (cache_key)
);

CREATE INDEX ix_markdown_cache_url_blog_post_id
    ON markdown_cache(url, blog_post_id);

Dieses Muster -

graph TB
    subgraph "Load Balancer"
        LB[Load Balancer]
    end

    subgraph "Application Servers"
        A1[App Server 1<br/>FetchExtension]
        A2[App Server 2<br/>FetchExtension]
        A3[App Server 3<br/>FetchExtension]
    end

    subgraph "Shared Cache"
        PG[(PostgreSQL<br/>markdown_cache table)]
    end

    subgraph "External Content"
        R1[Remote URL 1]
        R2[Remote URL 2]
        R3[Remote URL 3]
    end

    LB --> A1
    LB --> A2
    LB --> A3

    A1 <-->|Read/Write Cache| PG
    A2 <-->|Read/Write Cache| PG
    A3 <-->|Read/Write Cache| PG

    A1 -.->|Fetch if cache miss| R1
    A2 -.->|Fetch if cache miss| R2
    A3 -.->|Fetch if cache miss| R3

abgestanden-während-revalidiert

- ist entscheidend für die Zuverlässigkeit.

Auch wenn GitHub unten ist, arbeitet Ihre Website immer mit zwischengespeicherten Inhalten.

dotnet add package mostlylucid.Markdig.FetchExtension

Dateibasierte Speicherung: Einfache Persistenz

# For in-memory (demos/testing)
# Already included in base package

# For file-based storage
# Already included in base package

# For PostgreSQL
dotnet add package mostlylucid.Markdig.FetchExtension.Postgres

# For SQLite
dotnet add package mostlylucid.Markdig.FetchExtension.Sqlite

# For SQL Server
dotnet add package mostlylucid.Markdig.FetchExtension.SqlServer

Für Single-Server-Bereitstellungen funktioniert der dateibasierte Speicher hervorragend:

Wesentliche Punkte:Program.cs:

using Mostlylucid.Markdig.FetchExtension;

var builder = WebApplication.CreateBuilder(args);

// Option 1: In-Memory (simplest)
builder.Services.AddInMemoryMarkdownFetch();

// Option 2: File-Based (persists across restarts)
builder.Services.AddFileBasedMarkdownFetch("./markdown-cache");

// Option 3: PostgreSQL (multi-server)
builder.Services.AddPostgresMarkdownFetch(
    builder.Configuration.GetConnectionString("MarkdownCache"));

// Option 4: SQLite (single server with DB)
builder.Services.AddSqliteMarkdownFetch("Data Source=markdown-cache.db");

// Option 5: SQL Server (enterprise)
builder.Services.AddSqlServerMarkdownFetch(
    builder.Configuration.GetConnectionString("MarkdownCache"));

var app = builder.Build();

// If using database storage, ensure schema exists
if (app.Environment.IsDevelopment())
{
    app.Services.EnsureMarkdownCacheDatabase();
}

// Configure the extension with your service provider
FetchMarkdownExtension.ConfigureServiceProvider(app.Services);

app.Run();

Verwendung

für threadsicheren Dateizugriff

public class MarkdownRenderingService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly MarkdownFetchPreprocessor _preprocessor;
    private readonly MarkdownPipeline _pipeline;

    public MarkdownRenderingService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
        _preprocessor = new MarkdownFetchPreprocessor(serviceProvider);

        _pipeline = new MarkdownPipelineBuilder()
            .UseAdvancedExtensions()
            .UseSyntaxHighlighting()
            .UseToc()  // Add TOC support for [TOC] markers
            .UseYourCustomExtensions()
            .Build();
    }

    public string RenderMarkdown(string markdown)
    {
        // Step 1: Preprocess to handle fetch tags
        var processed = _preprocessor.Preprocess(markdown);

        // Step 2: Run through your normal Markdig pipeline
        return Markdown.ToHtml(processed, _pipeline);
    }
}

Hashes die URL + Blog-Post-ID, um sichere Dateinamen zu erstellen

  1. Verwendet Datei Änderung Zeit, um Frische zu bestimmen<fetch>Persistiert über Anwendungs-Neustarts
  2. Gleiches Stil-während-revalidiertes Muster
  3. Datenbankspeicher: Produktionsbereit
  4. Für Produktions-Deployments, insbesondere Multiserver-Setups, möchten Sie einen gemeinsamen Cache.

Hier kommen die Datenbankanbieter ins Spiel:

Das Datenbankschema ist einfach:

In einer Multi-Server-Bereitstellung gibt Ihnen dies die Konsistenz des Caches über alle Instanzen hinweg:**Alle Server teilen den gleichen Cache.**Wenn Server 1 eine README holt, profitieren Server 2 und 3 sofort von dem zwischengespeicherten Inhalt.

Einrichtung der Erweiterung

Der Einstieg ist einfach.[TOC]Installieren Sie zunächst das Basispaket:

# My Document

[TOC]

# Introduction
Content here...

# Getting Started
More content...

## Installation
Details...

Dann wählen Sie Ihren Speicheranbieter:

<nav class="ml_toc" aria-label="Table of Contents">
  <ul>
    <li><a href="#introduction">Introduction</a></li>
    <li><a href="#getting-started">Getting Started</a>
      <ul>
        <li><a href="#installation">Installation</a></li>
      </ul>
    </li>
  </ul>
</nav>

Konfiguration in ASP.NET Core

In Ihrem

[TOC cssclass="my-custom-toc"]

Integration mit Ihrem Markdown Rendering

<nav class="my-custom-toc" aria-label="Table of Contents">
  <!-- TOC content -->
</nav>

Der Schlüssel ist der Vorverarbeitungsschritt.

  1. **Hier ist, wie ich es in meinem Blog integrieren:**Der Fluss ist:

  2. Ihr Markdown enthältTags

    • Preprocessor löst sie auf tatsächliche Markdownid="getting-started"
    • Der kombinierte Abschlag geht durch Markdigid="api-reference"
  3. **Alles bekommt Ihre kundenspezifischen Erweiterungen, Styling, etc.**Erweiterte Funktionenul/liInhaltsverzeichnis Erzeugung

Das Paket enthält jetzt eine

getrennt

var pipeline = new MarkdownPipelineBuilder()
    .UseAdvancedExtensions()
    .UseToc()  // Add TOC support - position in pipeline doesn't matter!
    .Use<YourOtherExtensions>()
    .Build();

**Inhaltsverzeichnis (TOC) Erweiterung!**Während es neben der fetch-Erweiterung verpackt ist, ist es völlig unabhängig und kann auf eigene Faust verwendet werden.**Sie können automatisch ein anklickbares Inhaltsverzeichnis aus den Überschriften Ihres Dokuments generieren.**Grundlegende Verwendung:

  • Einfach hinzufügen
  • überall in Ihrem Markdown:

Dies erzeugt eine verschachtelte Liste aller Überschriften mit Anker-Links:.UseToc()Benutzerdefinierte CSS-Klassen:

**Sie können eine benutzerdefinierte CSS-Klasse für Styling angeben:**Dies wird mit Ihrer benutzerdefinierten Klasse wiedergegeben:

  • Wie es funktioniert:
  • Automatische Erkennung
  • : Der TOC erkennt automatisch die minimale Überschrift in Ihrem Dokument und passt sich entsprechend an.

**Wenn Ihr Dokument mit H2 beginnt, behandelt das TOC H2 als oberstes Level.**ID-Erstellung[TOC]: Überschriften werden automatisch IDs für die Ankerverknüpfung gegeben:.UseToc()"Erster Anfang" →

„API-Referenz" →

Verschachtelte Struktur

<!-- Failed to fetch content from https://raw.githubusercontent.com/user/repo/main/docs/README.md: HTTP 404: Not Found -->

: Der Renderer baut eine richtig verschachtelte

  • ./CONTRIBUTING.md → https://github.com/user/repo/blob/main/docs/CONTRIBUTING.md
  • ../images/logo.png → https://github.com/user/repo/blob/main/images/logo.png
  • Struktur, die Ihre Dokumenthierarchie widerspiegelt.

TOC-Unterstützung aktivieren:

public class MarkdownLinkRewriter
{
    public static string RewriteLinks(string markdown, string sourceUrl)
    {
        var document = Markdown.Parse(markdown);
        var baseUri = GetBaseUri(sourceUrl);

        foreach (var link in document.Descendants<LinkInline>())
        {
            if (IsRelativeLink(link.Url))
            {
                link.Url = ResolveRelativeLink(baseUri, link.Url);
            }
        }

        using var writer = new StringWriter();
        var renderer = new NormalizeRenderer(writer);
        renderer.Render(document);
        return writer.ToString();
    }

    private static bool IsRelativeLink(string url)
    {
        if (string.IsNullOrEmpty(url)) return false;
        if (url.StartsWith("http://") || url.StartsWith("https://")) return false;
        if (url.StartsWith("#")) return false;  // Anchor
        if (url.StartsWith("mailto:")) return false;
        return true;
    }
}

Wenn Sie Ihre Markdig-Pipeline konfigurieren, fügen Sie die TOC-Erweiterung hinzu:

Rohrleitungsposition:

<!-- Failed to fetch content from https://api.example.com/status.md: HTTP request failed: Name or service not known (api.example.com:443) -->

Im Gegensatz zu einigen Markdig-Erweiterungen, die TOC-Erweiterung

ist egal, wo Sie es hinzufügenin der Pipeline.Die Erweiterung automatisch:

Fügt seinen Parser am Anfang der Parserliste ein (Position 0)

<!-- Failed to fetch content from https://example.com/docs.md: HTTP 404: Not Found -->

Renders, nachdem das Parsen abgeschlossen ist, sammeln Überschriften aus dem gesamten Dokument

So können Sie hinzufügen

überall - Anfang, Mitte oder Ende Ihrer Pipeline-Konfiguration.

  • {retrieved:format}Wichtig:
  • {age}Die TOC-Erweiterung ist völlig unabhängig von der fetch-Erweiterung.
  • {url}Sie sind nur zur Bequemlichkeit zusammengepackt.
  • {nextrefresh:format}Sie können:
  • {pollfrequency}TOC ohne Abruf verwenden
  • {status}Holen ohne TOC verwenden

Beides zusammen verwenden

Anmerkung:disable="true"Der TOC-Marker funktioniert sowohl in Ihren Haupt-Markierungsdateien als auch in heruntergeladenen Remote-Inhalten.

<!-- This will be processed and fetch content -->
<!-- Failed to fetch content from https://example.com/README.md: HTTP 404: Not Found -->

<!-- This will NOT be processed - useful for documentation -->
<!-- Failed to fetch content from https://example.com/README.md: HTTP 404: Not Found -->

Wenn Sie eine README von GitHub erhalten, die

  • , es erzeugt automatisch ein Inhaltsverzeichnis aus den Überschriften dieses Dokuments (angenommen, Sie haben hinzugefügt
  • zu Ihrer Pipeline).
  • Verknüpfungstransformation

Beim Abrufen von Remote-Markdown (besonders von GitHub) brechen relative Links ab.<fetch>Die Erweiterung kann sie automatisch umschreiben:<fetch-summary>Dies verwandelt sich:

<!-- No fetch data available for https://example.com/api/status.md -->

Bewahrt absolute URLs und Anker

Die Implementierung nutzt den Markdig AST, um Links neu zu schreiben:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddPostgresMarkdownFetch(connectionString);

        var sp = services.BuildServiceProvider();
        var eventPublisher = sp.GetRequiredService<IMarkdownFetchEventPublisher>();

        // Subscribe to events
        eventPublisher.FetchBeginning += (sender, args) =>
        {
            Console.WriteLine($"Fetching {args.Url}...");
        };

        eventPublisher.FetchCompleted += (sender, args) =>
        {
            var source = args.WasCached ? "cache" : "remote";
            Console.WriteLine($"Fetched {args.Url} from {source} in {args.Duration.TotalMilliseconds}ms");
        };

        eventPublisher.FetchFailed += (sender, args) =>
        {
            Console.WriteLine($"Failed to fetch {args.Url}: {args.ErrorMessage}");
        };
    }
}

Metadaten-Zusammenfassung abrufen

sequenceDiagram
    participant MD as Markdown Processor
    participant EP as Event Publisher
    participant FS as Fetch Service
    participant ST as Storage Backend
    participant L as Your Listeners

    MD->>EP: FetchBeginning
    EP->>L: Notify FetchBeginning
    EP->>FS: FetchMarkdownAsync(url)
    FS->>ST: Check Cache
    alt Cache Fresh
        ST-->>FS: Cached Content
        FS->>EP: FetchCompleted (cached=true)
    else Cache Stale/Missing
        FS->>FS: HTTP GET
        alt Success
            FS->>ST: Update Cache
            ST-->>FS: OK
            FS->>EP: FetchCompleted (cached=false)
        else Failure
            FS->>EP: FetchFailed
            EP->>L: Notify FetchFailed
        end
    end
    EP->>L: Notify FetchCompleted
    EP->>L: Notify ContentUpdated
    FS-->>MD: MarkdownFetchResult

    Note over L: Listeners can be: Logging, Metrics, Telemetry, Webhooks

Sie können den Lesern zeigen, wann der Inhalt zuletzt abgeholt wurde:

Das ergibt mit einer Fußzeile:

stateDiagram-v2
    [*] --> CheckCache: Fetch Request

    CheckCache --> Fresh: Cache exists & age < pollFrequency
    CheckCache --> Stale: Cache exists & age >= pollFrequency
    CheckCache --> Missing: No cache entry

    Fresh --> ReturnCached: Return cached content
    ReturnCached --> [*]

    Stale --> FetchRemote: Attempt HTTP GET
    Missing --> FetchRemote: Attempt HTTP GET

    FetchRemote --> UpdateCache: Success
    FetchRemote --> HasStale: Failure

    UpdateCache --> ReturnFresh: Return new content
    ReturnFresh --> [*]

    HasStale --> ReturnStale: Return stale cache
    HasStale --> ReturnError: No cache available

    ReturnStale --> [*]
    ReturnError --> [*]

    note right of Fresh
        pollFrequency = 0
        means always stale
    end note

    note right of HasStale
        Stale-while-revalidate
        pattern ensures uptime
    end note

Inhalt ausgelesen

  1. **https://api.example.com/status.md**am 06 Jan 2025 (vor 2 Stunden)
  2. **Oder passen Sie die Vorlage an:**Ausgabe:~~~~~
  3. Letzte Aktualisierung: 06.Januar 2025 14:30-19 Status: cached-tend-Nächste Aktualisierung: in 22 StundenVerfügbare Platzhalter:
  4. - Letztes Abrufdatum/Zeit- Menschlich lesbare Zeit seit dem Abrufen
  • Quell-URL- Wenn der Inhalt aktualisiert wird- Dauer des Caches in Stunden

- Cache-Status (frisch/cached/stale)

Deaktivieren der Verarbeitung für Dokumentation

public class MarkdownController : Controller
{
    private readonly IMarkdownFetchService _fetchService;

    public async Task<IActionResult> InvalidateCache(string url)
    {
        var removed = await _fetchService.RemoveCachedMarkdownAsync(url);

        if (removed)
        {
            return Ok(new { message = "Cache invalidated" });
        }

        return NotFound(new { message = "No cache entry found" });
    }
}

Wenn Sie Dokumentation über die fetch-Erweiterung schreiben (wie dieser Artikel!), benötigen Sie einen Weg, um die Tags anzuzeigen, ohne dass sie verarbeitet werden.

// GitHub webhook notifies of README update
app.MapPost("/webhooks/github", async (
    GitHubWebhookPayload payload,
    IMarkdownFetchService fetchService) =>
{
    if (payload.Repository?.FullName == "user/repo" &&
        payload.Commits?.Any(c => c.Modified?.Contains("README.md") == true) == true)
    {
        var url = "https://raw.githubusercontent.com/user/repo/main/README.md";
        await fetchService.RemoveCachedMarkdownAsync(url);

        return Results.Ok(new { message = "Cache invalidated" });
    }

    return Results.Ok(new { message = "No action needed" });
});

Verwenden Sie die

Attribut:

public class MarkdownFetchServiceTests
{
    [Fact]
    public async Task FetchMarkdownAsync_CachesContent()
    {
        // Arrange
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddInMemoryMarkdownFetch();
        var sp = services.BuildServiceProvider();

        var fetchService = sp.GetRequiredService<IMarkdownFetchService>();
        var url = "https://raw.githubusercontent.com/user/repo/main/README.md";

        // Act - First fetch (from network)
        var result1 = await fetchService.FetchMarkdownAsync(url, 24, 0);

        // Act - Second fetch (from cache)
        var result2 = await fetchService.FetchMarkdownAsync(url, 24, 0);

        // Assert
        Assert.True(result1.Success);
        Assert.True(result2.Success);
        Assert.Equal(result1.Content, result2.Content);
    }

    [Fact]
    public async Task FetchMarkdownAsync_ReturnsStaleOnFailure()
    {
        // Arrange
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddInMemoryMarkdownFetch();
        var sp = services.BuildServiceProvider();

        var fetchService = sp.GetRequiredService<IMarkdownFetchService>();
        var url = "https://httpstat.us/200?sleep=100";

        // Act - First fetch succeeds
        var result1 = await fetchService.FetchMarkdownAsync(url, 0, 0);

        // Change URL to fail
        var badUrl = "https://httpstat.us/500";

        // Act - Second fetch fails, should return stale
        var result2 = await fetchService.FetchMarkdownAsync(badUrl, 0, 0);

        // Assert
        Assert.True(result1.Success);
        // Even though fetch failed, we return success with stale content
        Assert.True(result2.Success);
    }
}

Das deaktivierte Tag bleibt im Markdown as-is, perfekt für:

Dokumentation über die Erweiterung selbst schreiben

  1. Erstellen von Beispielen in TutorialsAnzeige der Tag-Syntax ohne Auslösung von fetchesConcurrentDictionaryDies funktioniert für beide
  2. undTags:
  3. Veranstaltungssystem für die ÜberwachungDie Erweiterung veröffentlicht Ereignisse für alle Abrufoperationen:
  4. **Dadurch ist es einfach, sich in Application Insights, Prometheus oder Ihre Logging-Infrastruktur zu integrieren:**Caching-Strategie im DetailIHttpClientFactoryDas Caching-Verhalten folgt einem Zustands-Maschinenmuster:
  5. **Die wichtigsten Erkenntnisse hier:**Frischer Cache
  • Sofort zurück, kein Netzwerk-Hit

  • Zwischenspeicher

    • Versuchen Sie, frisch zu holen, aber fallen Sie zurück in den Stillstand, wenn holen fehlschlägt
  • Fehlender Cache

    • Muss Fehler holen oder zurückgeben

0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0

  • Immer frisch holen (nützlich zum Testen)
name: Publish Markdig.FetchExtension

on:
  push:
    tags:
      - 'fetchextension-v*.*.*'

permissions:
  id-token: write
  contents: read

jobs:
  build-and-publish:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Setup .NET
      uses: actions/setup-dotnet@v4
      with:
        dotnet-version: '9.0.x'

    - name: Extract version from tag
      id: get_version
      run: |
        TAG=${GITHUB_REF#refs/tags/fetchextension-v}
        echo "VERSION=$TAG" >> $GITHUB_OUTPUT

    - name: Build
      run: dotnet build Mostlylucid.Markdig.FetchExtension/Mostlylucid.Markdig.FetchExtension.csproj --configuration Release -p:Version=${{ steps.get_version.outputs.VERSION }}

    - name: Pack
      run: dotnet pack Mostlylucid.Markdig.FetchExtension/Mostlylucid.Markdig.FetchExtension.csproj --configuration Release --no-build -p:PackageVersion=${{ steps.get_version.outputs.VERSION }} --output ./artifacts

    - name: Login to NuGet (OIDC)
      id: nuget_login
      uses: NuGet/login@v1
      with:
        user: 'mostlylucid'

    - name: Publish to NuGet
      run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ steps.nuget_login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate

Dieses Muster wird als

abgestanden-während-revalidiert

und es ist ausgezeichnet für Zuverlässigkeit.

# My NuGet Package Documentation

Here's the official README from GitHub:

# Umami.Net

## UmamiClient

This is a .NET Core client for the Umami tracking API.
It's based on the Umami Node client, which can be found [here](https://github.com/umami-software/node).

You can see how to set up Umami as a docker
container [here](https://www.mostlylucid.net/blog/usingumamiforlocalanalytics).
You can read more detail about it's creation on my
blog [here](https://www.mostlylucid.net/blog/addingumamitrackingclientfollowup).

To use this client you need the following appsettings.json configuration:

```json
{
  "Analytics":{
    "UmamiPath" : "https://umamilocal.mostlylucid.net",
    "WebsiteId" : "32c2aa31-b1ac-44c0-b8f3-ff1f50403bee"
  },
}

Where UmamiPath is the path to your Umami instance and WebsiteId is the id of the website you want to track.

To use the client you need to add the following to your Program.cs:

using Umami.Net;

services.SetupUmamiClient(builder.Configuration);

This will add the Umami client to the services collection.

You can then use the client in two ways:

Track

  1. Inject the UmamiClient into your class and call the Track method:
 // Inject UmamiClient umamiClient
 await umamiClient.Track("Search", new UmamiEventData(){{"query", encodedQuery}});
  1. Use the UmamiBackgroundSender to track events in the background (this uses an IHostedService to send events in the background):
 // Inject UmamiBackgroundSender umamiBackgroundSender
await umamiBackgroundSender.Track("Search", new UmamiEventData(){{"query", encodedQuery}});

The client will send the event to the Umami API and it will be stored.

The UmamiEventData is a dictionary of key value pairs that will be sent to the Umami API as the event data.

There are additionally more low level methods that can be used to send events to the Umami API.

Track PageView

There's also a convenience method to track a page view. This will send an event to the Umami API with the url set (which counts as a pageview).

  await  umamiBackgroundSender.TrackPageView("api/search/" + encodedQuery, "searchEvent", eventData: new UmamiEventData(){{"query", encodedQuery}});
  
   await umamiClient.TrackPageView("api/search/" + encodedQuery, "searchEvent", eventData: new UmamiEventData(){{"query", encodedQuery}});

Here we're setting the url to "api/search/" + encodedQuery and the event type to "searchEvent". We're also passing in a dictionary of key value pairs as the event data.

Raw 'Send' method

On both the UmamiClient and UmamiBackgroundSender you can call the following method.



 Send(UmamiPayload? payload = null, UmamiEventData? eventData = null,
        string eventType = "event")

If you don't pass in a UmamiPayload object, the client will create one for you using the WebsiteId from the appsettings.json.

    public  UmamiPayload GetPayload(string? url = null, UmamiEventData? data = null)
    {
        var httpContext = httpContextAccessor.HttpContext;
        var request = httpContext?.Request;

        var payload = new UmamiPayload
        {
            Website = settings.WebsiteId,
            Data = data,
            Url = url ?? httpContext?.Request?.Path.Value,
            IpAddress = httpContext?.Connection?.RemoteIpAddress?.ToString(),
            UserAgent = request?.Headers["User-Agent"].FirstOrDefault(),
            Referrer = request?.Headers["Referer"].FirstOrDefault(),
           Hostname = request?.Host.Host,
        };
        
        return payload;
    }

You can see that this populates the UmamiPayload object with the WebsiteId from the appsettings.json, the Url, IpAddress, UserAgent, Referrer and Hostname from the HttpContext.

NOTE: eventType can only be "event" or "identify" as per the Umami API.

UmamiData

There's also a service that can be used to pull data from the Umami API. This is a service that allows me to pull data from my Umami instance to use in stuff like sorting posts by popularity etc...

To set it up you need to add a username and password for your umami instance to the Analytics element in your settings file:

    "Analytics":{
        "UmamiPath" : "https://umami.mostlylucid.net",
        "WebsiteId" : "1e3b7657-9487-4857-a9e9-4e1920aa8c42",
        "UserName": "admin",
        "Password": ""
     
    }

Then in your Program.cs you set up the UmamiDataService as follows:

    services.SetupUmamiData(config);

You can then inject the UmamiDataService into your class and use it to pull data from the Umami API.

Usage

Now you have the UmamiDataService in your service collection you can start using it!

Methods

The methods are all from the Umami API definition you can read about them here: https://umami.is/docs/api/website-stats

All returns are wrapped in an UmamiResults<T> object which has a Success property and a Result property. The Result property is the object returned from the Umami API.

public record UmamiResult<T>(HttpStatusCode Status, string Message, T? Data);

All requests apart from ActiveUsers have a base request object with two compulsory properties. I added convenience DateTimes to the base request object to make it easier to set the start and end dates.

public class BaseRequest
{
    [QueryStringParameter("startAt", isRequired: true)]
    public long StartAt => StartAtDate.ToMilliseconds(); // Timestamp (in ms) of starting date
    [QueryStringParameter("endAt", isRequired: true)]
    public long EndAt => EndAtDate.ToMilliseconds(); // Timestamp (in ms) of end date
    public DateTime StartAtDate { get; set; }
    public DateTime EndAtDate { get; set; }
}

The service has the following methods:

Active Users

This just gets the total number of CURRENT active users on the site

public async Task<UmamiResult<ActiveUsersResponse>> GetActiveUsers()

Stats

This returns a bunch of statistics about the site, including the number of users, page views, etc.

public async Task<UmamiResult<StatsResponseModels>> GetStats(StatsRequest statsRequest)    

You may set a number of parameters to filter the data returned from the API. For instance using url will return the stats for a specific URL.

StatsRequest object
public class StatsRequest : BaseRequest
{
    [QueryStringParameter("url")]
    public string? Url { get; set; } // Name of URL
    
    [QueryStringParameter("referrer")]
    public string? Referrer { get; set; } // Name of referrer
    
    [QueryStringParameter("title")]
    public string? Title { get; set; } // Name of page title
    
    [QueryStringParameter("query")]
    public string? Query { get; set; } // Name of query
    
    [QueryStringParameter("event")]
    public string? Event { get; set; } // Name of event
    
    [QueryStringParameter("host")]
    public string? Host { get; set; } // Name of hostname
    
    [QueryStringParameter("os")]
    public string? Os { get; set; } // Name of operating system
    
    [QueryStringParameter("browser")]
    public string? Browser { get; set; } // Name of browser
    
    [QueryStringParameter("device")]
    public string? Device { get; set; } // Name of device (e.g., Mobile)
    
    [QueryStringParameter("country")]
    public string? Country { get; set; } // Name of country
    
    [QueryStringParameter("region")]
    public string? Region { get; set; } // Name of region/state/province
    
    [QueryStringParameter("city")]
    public string? City { get; set; } // Name of city
}

The JSON object Umami returns is as follows.

{
  "pageviews": { "value": 5, "change": 5 },
  "visitors": { "value": 1, "change": 1 },
  "visits": { "value": 3, "change": 2 },
  "bounces": { "value": 0, "change": 0 },
  "totaltime": { "value": 4, "change": 4 }
}

This is wrapped inside my StatsResponseModel object.

namespace Umami.Net.UmamiData.Models.ResponseObjects;

public class StatsResponseModels
{
    public Pageviews pageviews { get; set; }
    public Visitors visitors { get; set; }
    public Visits visits { get; set; }
    public Bounces bounces { get; set; }
    public Totaltime totaltime { get; set; }


    public class Pageviews
    {
        public int value { get; set; }
        public int prev { get; set; }
    }

    public class Visitors
    {
        public int value { get; set; }
        public int prev { get; set; }
    }

    public class Visits
    {
        public int value { get; set; }
        public int prev { get; set; }
    }

    public class Bounces
    {
        public int value { get; set; }
        public int prev { get; set; }
    }

    public class Totaltime
    {
        public int value { get; set; }
        public int prev { get; set; }
    }
}

Metrics

Metrics in Umami provide you the number of views for specific types of properties.

Events

One example of these is Events`:

'Events' in Umami are specific items you can track on a site. When tracking events using Umami.Net you can set a number of properties which are tracked with the event name. For instance here I track Search requests with the URL and the search term.

       await  umamiBackgroundSender.Track( "searchEvent", eventData: new UmamiEventData(){{"query", encodedQuery}});

To fetch data about this event you would use the Metrics method:

public async Task<UmamiResult<MetricsResponseModels[]>> GetMetrics(MetricsRequest metricsRequest)

As with the other methods this accepts the MetricsRequest object (with the compulsory BaseRequest properties) and a number of optional properties to filter the data.

MetricsRequest object
public class MetricsRequest : BaseRequest
{
    [QueryStringParameter("type", isRequired: true)]
    public MetricType Type { get; set; } // Metrics type

    [QueryStringParameter("url")]
    public string? Url { get; set; } // Name of URL
    
    [QueryStringParameter("referrer")]
    public string? Referrer { get; set; } // Name of referrer
    
    [QueryStringParameter("title")]
    public string? Title { get; set; } // Name of page title
    
    [QueryStringParameter("query")]
    public string? Query { get; set; } // Name of query
    
    [QueryStringParameter("host")]
    public string? Host { get; set; } // Name of hostname
    
    [QueryStringParameter("os")]
    public string? Os { get; set; } // Name of operating system
    
    [QueryStringParameter("browser")]
    public string? Browser { get; set; } // Name of browser
    
    [QueryStringParameter("device")]
    public string? Device { get; set; } // Name of device (e.g., Mobile)
    
    [QueryStringParameter("country")]
    public string? Country { get; set; } // Name of country
    
    [QueryStringParameter("region")]
    public string? Region { get; set; } // Name of region/state/province
    
    [QueryStringParameter("city")]
    public string? City { get; set; } // Name of city
    
    [QueryStringParameter("language")]
    public string? Language { get; set; } // Name of language
    
    [QueryStringParameter("event")]
    public string? Event { get; set; } // Name of event
    
    [QueryStringParameter("limit")]
    public int? Limit { get; set; } = 500; // Number of events returned (default: 500)
}

Here you can see that you can specify a number of properties in the request element to specify what metrics you want to return.

You can also set a Limit property to limit the number of results returned.

For instance to get the event over the past day I mentioned above you would use the following request:

var metricsRequest = new MetricsRequest
{
    StartAtDate = DateTime.Now.AddDays(-1),
    EndAtDate = DateTime.Now,
    Type = MetricType.@event,
    Event = "searchEvent"
};

The JSON object returned from the API is as follows:

[
  { "x": "searchEvent", "y": 46 }
]

And again I wrap this in my MetricsResponseModels object.

public class MetricsResponseModels
{
    public string x { get; set; }
    public int y { get; set; }
}

Where x is the event name and y is the number of times it has been triggered.

Page Views

One of the most useful metrics is the number of page views. This is the number of times a page has been viewed on the site. Below is the test I use to get the number of page views over the past 30 days. You'll note the Type parameter is set as MetricType.url however this is also the default value so you don't need to set it.

  [Fact]
    public async Task Metrics_StartEnd()
    {
        var setup = new SetupUmamiData();
        var serviceProvider = setup.Setup();
        var websiteDataService = serviceProvider.GetRequiredService<UmamiDataService>();
        
        var metrics = await websiteDataService.GetMetrics(new MetricsRequest()
        {
            StartAtDate = DateTime.Now.AddDays(-30),
            EndAtDate = DateTime.Now,
            Type = MetricType.url,
            Limit = 500
        });
        Assert.NotNull(metrics);
        Assert.Equal( HttpStatusCode.OK, metrics.Status);

    }

This returns a MetricsResponse object which has the following JSON structure:

[
  {
    "x": "/",
    "y": 1
  },
  {
    "x": "/blog",
    "y": 1
  },
  {
    "x": "/blog/usingumamidataforwebsitestats",
    "y": 1
  }
]

Where x is the URL and y is the number of times it has been viewed.

PageViews

This returns the number of page views for a specific URL.

Again here is a test I use for this method:

    [Fact]
    public async Task PageViews_StartEnd_Day_Url()
    {
        var setup = new SetupUmamiData();
        var serviceProvider = setup.Setup();
        var websiteDataService = serviceProvider.GetRequiredService<UmamiDataService>();
    
        var pageViews = await websiteDataService.GetPageViews(new PageViewsRequest()
        {
            StartAtDate = DateTime.Now.AddDays(-7),
            EndAtDate = DateTime.Now,
            Unit = Unit.day,
            Url = "/blog"
        });
        Assert.NotNull(pageViews);
        Assert.Equal( HttpStatusCode.OK, pageViews.Status);

    }

This returns a PageViewsResponse object which has the following JSON structure:

[
  {
    "date": "2024-09-06 00:00",
    "value": 1
  }
]

Where date is the date and value is the number of page views, this is repeated for each day in the range specified ( or hour, month, etc. depending on the Unit property).

As with the other methods this accepts the PageViewsRequest object (with the compulsory BaseRequest properties) and a number of optional properties to filter the data.

PageViewsRequest object
public class PageViewsRequest : BaseRequest
{
    // Required properties

    [QueryStringParameter("unit", isRequired: true)]
    public Unit Unit { get; set; } = Unit.day; // Time unit (year | month | hour | day)
    
    [QueryStringParameter("timezone")]
    [TimeZoneValidator]
    public string Timezone { get; set; }

    // Optional properties
    [QueryStringParameter("url")]
    public string? Url { get; set; } // Name of URL
    [QueryStringParameter("referrer")]
    public string? Referrer { get; set; } // Name of referrer
    [QueryStringParameter("title")]
    public string? Title { get; set; } // Name of page title
    [QueryStringParameter("host")]
    public string? Host { get; set; } // Name of hostname
    [QueryStringParameter("os")]
    public string? Os { get; set; } // Name of operating system
    [QueryStringParameter("browser")]
    public string? Browser { get; set; } // Name of browser
    [QueryStringParameter("device")]
    public string? Device { get; set; } // Name of device (e.g., Mobile)
    [QueryStringParameter("country")]
    public string? Country { get; set; } // Name of country
    [QueryStringParameter("region")]
    public string? Region { get; set; } // Name of region/state/province
    [QueryStringParameter("city")]
    public string? City { get; set; } // Name of city
}

As with the other methods you can set a number of properties to filter the data returned from the API, for instance you could set the Country property to get the number of page views from a specific country.

Using the Service

In this site I have some code which lets me use this service to get the number of views each blog page has. In the code below I take a start and end date and a prefix (which is /blog in my case) and get the number of views for each page in the blog.

I then cache this data for an hour so I don't have to keep hitting the Umami API.

public class UmamiDataSortService(
    UmamiDataService dataService,
    IMemoryCache cache)
{
    public async Task<List<MetricsResponseModels>?> GetMetrics(DateTime startAt, DateTime endAt, string prefix="" )
    {
        using var activity = Log.Logger.StartActivity("GetMetricsWithPrefix");
        try
        {
            var cacheKey = $"Metrics_{startAt}_{endAt}_{prefix}";
            if (cache.TryGetValue(cacheKey, out List<MetricsResponseModels>? metrics))
            {
                activity?.AddProperty("CacheHit", true);
                return metrics;
            }
            activity?.AddProperty("CacheHit", false);
            var metricsRequest = new MetricsRequest()
            {
                StartAtDate = startAt,
                EndAtDate = endAt,
                Type = MetricType.url,
                Limit = 500
            };
            var metricRequest = await dataService.GetMetrics(metricsRequest);

            if(metricRequest.Status != HttpStatusCode.OK)
            {
                return null;
            }
            var filteredMetrics = metricRequest.Data.Where(x => x.x.StartsWith(prefix)).ToList();
            cache.Set(cacheKey, filteredMetrics, TimeSpan.FromHours(1));
            activity?.AddProperty("MetricsCount", filteredMetrics?.Count()?? 0);
            activity?.Complete();
            return filteredMetrics;
        }
        catch (Exception e)
        {
            activity?.Complete(LogEventLevel.Error, e);
         
            return null;
        }
    }

Installation

The package is available on NuGet...


Auch wenn Ihre Quelle nach unten geht, Ihre Website dient immer zwischengespeicherte Inhalte.

# Entfernung und Verwaltung von Caches

Manchmal müssen Sie den Cache manuell ungültig machen:

1. **Oder über Webhooks, wenn sich der Inhalt ändert:**Prüfung der Erweiterung
2. **Die Erweiterung umfasst umfassende Tests.**So strukturiere ich sie:
3. **Leistungserwägungen**Die Erweiterung ist für die Leistung ausgelegt:
4. **Gleichzeitiges Wörterbuch**- Einsatz von In-Memory-Implementierung
5. **für fadensicheren Zugriff**SemaphoreSlim
6. **- File-based verwendet async Sperrung, um Wettlaufbedingungen zu verhindern**Datenbankverzeichnisse

- Alle Datenbankanbieter haben korrekte Indexe auf Cache-Schlüsseln

- [HTTP-Client-Pooling](https://www.nuget.org/packages/mostlylucid.Markdig.FetchExtension)
- [- Verwendung](https://www.nuget.org/packages/mostlylucid.Markdig.FetchExtension.Postgres)
- [für effiziente Wiederverwendung der Verbindung](https://www.nuget.org/packages/mostlylucid.Markdig.FetchExtension.Sqlite)
- [Den ganzen Weg async](https://www.nuget.org/packages/mostlylucid.Markdig.FetchExtension.SqlServer)

- Keine Anrufe blockieren, alles ist async[Typische Leistungszahlen auf meinem Home-Server:](https://github.com/scottgal/mostlylucidweb/tree/main/Mostlylucid.Markdig.FetchExtension)

Treffer im Cache: < 1ms
Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.