Mert Özen Her Satırda Daha İleri
Backend .NET'te E-posta, PDF, Excel ve Dosya İşleri — #01

Backend'de Doküman ve Bildirim İşleri: Request İçinde mi Background Job'da mı, Kütüphane Lisansları ve Abstraction

Mert Özen 15 Kas 2026 13 dk 6 görüntülenme
Backend'de Doküman ve Bildirim İşleri: Request İçinde mi Background Job'da mı, Kütüphane Lisansları ve Abstraction

Küçük ve hızlı dokümanı request içinde üretip stream et; yavaş, büyük ya da tekrar denenmesi gereken işi 202 Accepted ile kabul edip background job'a ver, hazır olunca e-postayla haber ver. Bookstore'da 8 saniyelik rapor senkron yolda 5 saniyede 504 dönüyor, asenkron yolda 16 ms'de 202; QuestPDF, EPPlus ve ImageSharp lisanslarını ve ImageSharp 4'ün Release build'i durduran lisans kontrolünü CI'da nuget-license ile takip ediyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve nuget-license 4.0.18 ile derlenip denenmiştir. Lisans bilgileri 15 Kasım 2026'da NuGet'ten ve kütüphanelerin resmi lisans sayfalarından kontrol edildi.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) bu seride üç işi öğreniyor: e-posta göndermek, PDF ve Excel üretmek, dosyaları saklamak. Back office'ten Selin aylık satış raporunu istiyor. Rapor 2.000 satır ve üretmesi 8 saniye sürüyor. Endpoint'e 5 saniyelik bir request timeout'u koyduk. Üç soru:

Birincisi: Selin rapor linkine tıkladığında ne görür? 8 saniye bekleyip dosyayı mı alır, yoksa başka bir şey mi?

İkincisi: aynı raporu bir job'a verip hemen yanıt dönersek, ilk yanıt kaç milisaniyede gelir ve hangi status code ile?

Üçüncüsü: projeye PDF için QuestPDF, görseller için ImageSharp 4 ekledin ve her şey Debug'da derleniyor. CI'daki Release build'de ne olur?

Cevaplar bu yazıdaki gerçek koşudan: ilki 5,02 saniyede boş bir 504. İkincisi 16 milisaniyede 202 Accepted. Üçüncüsü ise derleme hatası: ImageSharp 4, lisans anahtarı olmadan Release build'i durduruyor. Sonuncusu bu yazının ikinci yarısının konusu.

Doküman İşlerinin Ortak Noktası

E-posta göndermek, PDF fatura üretmek, Excel raporu hazırlamak ve yüklenen kapak görselini küçültmek ilk bakışta birbirinden çok farklı işler. Backend açısından ise aynı üç özelliği taşıyorlar.

Yavaşlar. Bir fatura PDF'i milisaniyeler sürer, ama 100 bin satırlık bir Excel ya da yüzlerce sayfalık bir rapor saniyeler, bazen dakikalar sürer. Süre veriyle birlikte büyür ve bugün hızlı olan rapor altı ay sonra yavaşlar.

Dış bağımlılıkları var. E-posta için bir SMTP (e-postayı sunucudan sunucuya taşıyan protokol) sunucusu, dosyalar için disk ya da object storage, bazen de bir font dosyası ya da headless bir tarayıcı gerekiyor. Bunların her biri ara sıra hata verir.

Tekrar denenebilir olmaları gerekiyor. SMTP sunucusu geçici bir 451 döndüğünde e-postayı kaybetmek istemezsin; ama tekrar denediğinde müşteriye iki onay e-postası gitmesini de istemezsin.

Bu üç özellik, kodu nereye koyacağın sorusunu doğuruyor: işi HTTP request'inin içinde mi yapacaksın, yoksa request'i hemen yanıtlayıp işi bir background job'a mı vereceksin? Bu yazıda iki yolu da Bookstore.Api'de yan yana kuruyoruz ve ölçüyoruz.

Senkron Yol: Request İçinde Üretip Stream Etmek

Tek siparişin faturası küçük ve hızlı bir doküman. Kullanıcı "faturamı indir" dediğinde onu bekletmeye değmez; üretip doğrudan yanıta yazmak en basit çözüm:

// Senkron: küçük ve hızlı doküman doğrudan yanıta yazılır
app.MapGet("/api/orders/{orderId:int}/invoice", (int orderId, IDocumentRenderer<InvoiceModel> renderer, CancellationToken ct) =>
{
    InvoiceModel invoice = BookstoreSampleData.Invoice(orderId);
    return Results.Stream(
        stream => renderer.RenderAsync(invoice, stream, ct),
        renderer.Format.ContentType,
        fileDownloadName: $"invoice-{invoice.InvoiceNumber}{renderer.Format.Extension}");
});

Results.Stream'in callback alan overload'u dokümanı önce belleğe toplayıp sonra göndermiyor; renderer yazdıkça byte'lar yanıta gidiyor. Bu yazıda renderer henüz düz metin üretiyor, beşinci yazıda yerine QuestPDF ile gerçek PDF gelecek. Endpoint'in kendisi değişmeyecek, çünkü yalnız IDocumentRenderer<InvoiceModel>'ı tanıyor.

$ curl -s -D - http://127.0.0.1:52560/api/orders/1042/invoice
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment; filename=invoice-BKS-2026-001042.txt; filename*=UTF-8''invoice-BKS-2026-001042.txt

Invoice BKS-2026-001042 / order 1042
2 x Dune                ₺900,00
1 x The Hobbit          ₺380,00
1 x Emma                ₺240,00
1 x Gift wrapping        ₺25,00
Total incl. VAT: ₺1.702,00

Yanıt 41 milisaniyede geldi. Content-Disposition'daki filename* kısmını ASP.NET Core kendisi ekledi; dosya adında Türkçe karakter olduğunda bunun neden önemli olduğunu dördüncü yazıda göreceğiz.

Yavaş Doküman Request'e Ne Yapar?

Aynı yaklaşımı aylık rapora uygulayalım. Excel renderer'ı dördüncü yazıda geliyor; o zamana kadar raporun gerçek maliyetini Task.Delay ile 8 saniyelik bir bekleme olarak taklit ediyoruz. Endpoint'e de ASP.NET Core'un request timeout middleware'i ile 5 saniyelik bir sınır koyuyoruz:

builder.Services.AddRequestTimeouts();
// ...
app.UseRequestTimeouts();

// Kasıtlı olarak senkron: yavaş bir dokümanın request'e ne yaptığını görmek için
app.MapGet("/api/reports/monthly-sales/{month}", async (string month, IDocumentRenderer<MonthlySalesReport> renderer, CancellationToken ct) =>
{
    MonthlySalesReport report = BookstoreSampleData.MonthlySales(DateOnly.ParseExact(month + "-01", "yyyy-MM-dd"), rowCount: 2_000);
    using MemoryStream buffer = new();
    await renderer.RenderAsync(report, buffer, ct);
    return Results.File(buffer.ToArray(), renderer.Format.ContentType, $"monthly-sales-{month}{renderer.Format.Extension}");
}).WithRequestTimeout(TimeSpan.FromSeconds(5));
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://127.0.0.1:52560/api/reports/monthly-sales/2026-10
504 5.021302s

Tahmin sorusunun ilk cevabı bu: Selin 5 saniye bekliyor ve elinde boş bir 504 kalıyor. Middleware süre dolunca HttpContext.RequestAborted'ı iptal ediyor; renderer'a geçtiğimiz ct de o token olduğu için Task.Delay OperationCanceledException fırlatıyor ve middleware 504 dönüyor. Renderer token'ı dinlemeseydi iş 8 saniye boyunca sunucuda dönmeye devam ederdi; middleware işi zorla durdurmuyor, yalnız iptal sinyali veriyor. Microsoft'un dokümanı bir şeyi daha hatırlatıyor: uygulama debugger altında çalışırken bu middleware devreye girmiyor. Timeout'u denemek istiyorsan uygulamayı debugger'sız başlat.

Timeout'u kaldırsaydık Selin dosyayı 8 saniyede alırdı. Sorun şu ki 8 saniye bugünün sayısı. Satırlar arttıkça süre uzar, bu arada load balancer'ın ya da reverse proxy'nin kendi timeout'u devreye girer ve kullanıcı sayfayı yeniler. Yenilenen her istek, yarım kalan işin üstüne aynı raporu baştan üretmeye başlar.

Asenkron Yol: Kabul Et, Job'a Ver, Hazır Olunca Haber Ver

Raporu üretme işini request'ten ayırıyoruz. Endpoint isteği kabul ediyor, bir queue'ya yazıyor ve hemen 202 dönüyor. Bir BackgroundService queue'dan job'u alıp raporu üretiyor, dosyayı IFileStore'a yazıyor ve Selin'e e-postayla link gönderiyor:

01-sync-vs-async-tr.png

// Asenkron: kabul et, queue'ya yaz, durum URL'iyle 202 dön
app.MapPost("/api/reports/monthly-sales", async (MonthlySalesRequest request, ReportJobQueue queue, ReportJobStore store, CancellationToken ct) =>
{
    ReportJob job = new(Guid.CreateVersion7(), DateOnly.ParseExact(request.Month + "-01", "yyyy-MM-dd"), request.NotifyEmail);
    ReportJobStatus queued = new(job.Id, ReportJobState.Queued);
    store.Set(queued);
    await queue.EnqueueAsync(job, ct);
    return Results.Accepted($"/api/reports/{job.Id}", queued);
});

Worker'ın gövdesi üç abstraction'ı sırayla çağırıyor:

await foreach (ReportJob job in queue.ReadAllAsync(stoppingToken))
{
    store.Set(new ReportJobStatus(job.Id, ReportJobState.Running));
    try
    {
        MonthlySalesReport report = BookstoreSampleData.MonthlySales(job.Month, rowCount: 2_000);
        string path = $"reports/monthly-sales-{job.Month:yyyy-MM}-{job.Id:N}{renderer.Format.Extension}";

        using MemoryStream buffer = new();
        await renderer.RenderAsync(report, buffer, stoppingToken);
        buffer.Position = 0;
        StoredFile file = await fileStore.SaveAsync(path, buffer, renderer.Format.ContentType, stoppingToken);

        await emailSender.SendAsync(new EmailMessage
        {
            To = new EmailAddress(job.NotifyEmail),
            Subject = $"Monthly sales report {job.Month:yyyy-MM} is ready",
            HtmlBody = $"<p>Your report is ready: <a href=\"/api/reports/{job.Id}/file\">download</a></p>",
            TextBody = $"Your report is ready: /api/reports/{job.Id}/file",
        }, stoppingToken);

        store.Set(new ReportJobStatus(job.Id, ReportJobState.Completed, file.Path));
    }
    catch (Exception ex) when (ex is not OperationCanceledException)
    {
        // Başarısız bir rapor worker döngüsünü durdurmamalı
        store.Set(new ReportJobStatus(job.Id, ReportJobState.Failed, Error: ex.Message));
        logger.LogError(ex, "Report job {JobId} failed", job.Id);
    }
}

Queue, Background Job'lar serisinin ilk yazısında anlatılan bounded bir Channel<ReportJob>. Durum bilgisi bellekteki bir ConcurrentDictionary'de duruyor. İkisi de bu yazının amacına yetiyor ama uygulama yeniden başlayınca kayboluyor; kalıcı hâlini serinin son yazısında Hangfire ile kuracağız. Gerçek koşu:

$ curl -s -i -X POST http://127.0.0.1:52560/api/reports/monthly-sales -H "Content-Type: application/json" -d '{"month":"2026-10","notifyEmail":"selin@example.com"}'
HTTP/1.1 202 Accepted
Content-Type: application/json; charset=utf-8
Location: /api/reports/01a1ddae-6dc1-71af-8a0c-236b0c7c8d91

{"id":"01a1ddae-6dc1-71af-8a0c-236b0c7c8d91","state":"Queued"}

$ curl -s http://127.0.0.1:52560/api/reports/01a1ddae-6dc1-71af-8a0c-236b0c7c8d91
{"id":"01a1ddae-6dc1-71af-8a0c-236b0c7c8d91","state":"Running"}

$ curl -s http://127.0.0.1:52560/api/reports/01a1ddae-6dc1-71af-8a0c-236b0c7c8d91
{"id":"01a1ddae-6dc1-71af-8a0c-236b0c7c8d91","state":"Completed","filePath":"reports/monthly-sales-2026-10-01a1ddae6dc171af8a0c236b0c7c8d91.txt"}

202 yanıtı 16 milisaniyede geldi. Dokuz saniye sonraki istekte job tamamlanmıştı ve uygulama log'unda iki satır vardı:

info: Bookstore.Notifications.Email.LoggingEmailSender[0]
      Email to selin@example.com: Monthly sales report 2026-10 is ready (0 attachments)
info: Bookstore.Api.Reports.ReportJobWorker[0]
      Report job 01a1ddae-6dc1-71af-8a0c-236b0c7c8d91 completed: reports/monthly-sales-2026-10-01a1ddae6dc171af8a0c236b0c7c8d91.txt (114279 bytes)

E-posta şimdilik yalnız log'a yazılıyor. İkinci yazıda LoggingEmailSender'ın yerine MailKit ile gerçek SMTP gönderimi gelecek.

Hangi yolu seçeceğine karar verirken kendine üç soru sor. Doküman kullanıcının beklediği birkaç saniye içinde her zaman hazır olur mu? Üretim başarısız olursa kullanıcı tekrar tıklayıp sorunu kendisi çözebilir mi? Aynı doküman tekrar tekrar mı isteniyor, yoksa bir kez üretilip saklanması mı gerekiyor? Fatura için üç cevap da senkron yolu gösteriyor. Aylık rapor, toplu e-posta ve yüklenen görseli işlemek ise asenkron yola uyuyor. Fatura gibi tek bir dokümanın bile hem senkron hem asenkron yaşadığı durumlar var: sipariş anında job ile üretilip saklanır, sonraki indirmelerde storage'tan okunur.

Üç Abstraction: IEmailSender, IDocumentRenderer, IFileStore

Worker'ın hiçbir yerinde MailKit, QuestPDF ya da disk yolu geçmiyor. Bookstore'un Notifications module'ü bu üç interface'i tanımlıyor ve serinin geri kalanı onları implement ediyor:

public interface IEmailSender
{
    Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default);
}

public interface IDocumentRenderer<in TModel>
{
    FileFormat Format { get; }

    Task RenderAsync(TModel model, Stream output, CancellationToken cancellationToken = default);
}

public interface IFileStore
{
    Task<StoredFile> SaveAsync(string path, Stream content, string contentType, CancellationToken cancellationToken = default);

    // Yol yoksa FileNotFoundException
    Task<Stream> OpenReadAsync(string path, CancellationToken cancellationToken = default);

    Task<bool> DeleteAsync(string path, CancellationToken cancellationToken = default);
}

Bu interface'lerde bilinçli kararlar var. IDocumentRenderer byte dizisi dönmüyor, bir Stream'e yazıyor; böylece aynı renderer hem HTTP yanıtına hem dosyaya hem de belleğe yazabiliyor ve büyük dokümanlar belleğe sığmak zorunda kalmıyor. Model tipi generic parametre; fatura renderer'ı ile rapor renderer'ı dependency injection container'ında birbirine karışmıyor. IFileStore ise yalnız path ve stream biliyor. Sekizinci yazıda Azure Blob Storage ve S3 (Amazon'un object storage servisi) implementasyonları geldiğinde imzaya yalnız GetDownloadUrl eklenecek.

İlk implementasyonlar bilerek basit. LocalFileStore dosyayı önce geçici bir dosyaya yazıp sonra File.Move ile yerine koyuyor; okuyan taraf yarım yazılmış bir dosya görmüyor. Path'in root klasörün dışına çıkmasına da izin vermiyor. Asıl kazanç ise testte: SMTP sunucusu kurmadan raporun hazır olunca e-posta gönderdiğini doğrulayabiliyoruz:

FakeEmailSender email = new();
HttpClient client = factory.WithWebHostBuilder(builder =>
{
    builder.UseSetting("Reports:SimulatedRenderTime", "00:00:00.200");
    builder.ConfigureServices(services => services.Replace(ServiceDescriptor.Singleton<IEmailSender>(email)));
}).CreateClient();
// ... POST, Completed olana kadar durum sorgula ...
EmailMessage sent = Assert.Single(email.Sent);
Assert.Equal("selin@example.com", sent.To.Address);
Assert.Contains($"/api/reports/{status!.Id}/file", sent.TextBody);
$ dotnet test
Test run summary: Passed!
  total: 4
  failed: 0
  succeeded: 4
  skipped: 0

Lisans Haritası

Bu iş türlerinin hepsinde .NET'in kendi kütüphanesi ya yok ya da yetersiz. E-posta için System.Net.Mail.SmtpClient var ama Microsoft'un kendi dokümanı onu yeni geliştirme için önermiyor (ayrıntısı bir sonraki yazıda). PDF, Excel ve görsel işleme için ise üçüncü parti kütüphane seçmek zorundasın. Bu kütüphanelerin bir kısmı MIT (kodun ticari projelerde de serbestçe kullanılmasına izin veren kısa bir lisans) ya da Apache 2.0 gibi serbest lisanslarla geliyor, bir kısmı ise ticari kullanımı ücretli lisansa bağlıyor. 15 Kasım 2026'daki durum:

KütüphaneSürümLisansTicari kullanımda
MailKit5.0.0MITSerbest
ClosedXML0.105.1MITSerbest
PdfPig0.1.16Apache-2.0Serbest
SkiaSharp4.156.1MITSerbest
QuestPDF2026.9.2Community License 3.0 + ücretli lisansYıllık brüt geliri 1 milyon USD altındaki şirketler ücretsiz; kamu kurumları ve borsada işlem gören şirketler gelirden bağımsız ücretli
EPPlus8.7.2PolyForm Noncommercial 1.0.0 + ticari lisansTicari ortamda developer başına ücretli lisans
SixLabors.ImageSharp4.1.3Six Labors Split License 1.0Yıllık brüt geliri 1 milyon USD altı, open source ya da kâr amacı gütmeyen kullanımda Apache 2.0; aksi hâlde ücretli

QuestPDF'in lisans rehberi (6 Temmuz 2026 tarihli 3.0 sürümü) Community License'ı bireylere, son tamamlanan mali yılda yıllık brüt geliri 1 milyon USD'nin altında kalan şirketlere, OSI (open source lisanslarını onaylayan kuruluş) onaylı bir lisansla dağıtılan open source projelere, hayır kurumlarına ve kâr amacı gütmeyen akademik kurumlara veriyor. Gelir, aynı kontrol altındaki şirketlerin toplamı olarak hesaplanıyor; yani küçük bir yan şirket üzerinden eşiğin altına inemiyorsun. Uygun olmayan bir kurum, ücretli lisansı almadan önce kütüphaneyi 60 güne kadar yalnız değerlendirme amacıyla, production dışında deneyebiliyor. Lisans türünü kodda belirtmen de gerekiyor; beşinci yazıda bu satırı göreceğiz.

EPPlus 5. sürümden beri PolyForm Noncommercial lisanslı ve resmi lisans sayfası ticari ortamdaki kullanım için ticari lisans istiyor. EPPlus 8'de lisans, ExcelPackage.License.SetNonCommercialPersonal, SetNonCommercialOrganization ya da SetCommercial ile kodda belirtiliyor; belirtilmezse paketin kendi dokümanına göre LicenseNotSetException fırlatılıyor.

ImageSharp'ın lisansı Apache 2.0 koşullarını geliri 1 milyon USD altındaki şirketlere, open source projelere ve transitive bağımlılık olarak kullananlara tanıyor. 4.x serisinde bir ek daha var ve bunu lisans metninden değil, build çıktısından öğrendik. Paketi bir projeye ekleyip Release'te derledik:

$ dotnet build -c Release
SixLabors.ImageSharp.targets(35,5): error : No Six Labors license found. Set $(SixLaborsLicenseKey), set $(SixLaborsLicenseFile), or add a 'sixlabors.lic' file to the project/workspace.
SixLabors.ImageSharp.targets(35,5): error : Please obtain a license from https://sixlabors.com/pricing/
Build FAILED.

Debug build'de aynı mesaj uyarı olarak çıkıyor ve build devam ediyor. Uyarılardan biri "assembly-scoped community key"den bahsediyor; yani ücretsiz kullanım koşullarına uysan bile Release build için bir anahtar edinmen gerekiyor. Paket, lisans kontrolünü bir MSBuild target'ı olarak CoreCompile'dan önce çalıştırıyor. Geliştiricinin makinesinde her şey derlenirken CI'daki Release build'in kırılmasının nedeni bu. Görsel işlemeyi yedinci yazıda ayrıntılı ele alacağız; o yazıda SkiaSharp'ı da aynı işler için deneyeceğiz.

Lisansı CI'da Takip Etmek

Lisansı kütüphaneyi seçerken bir kez okumak yetmiyor. Transitive bağımlılıklar değişiyor ve bir kütüphane yeni sürümde lisansını değiştirebiliyor. QuestPDF bunun iyi bir örneği: NuGet'teki 2023 sürümlerinin lisans sayfası "QuestPDF Community MIT License" başlığını taşıyor, bugünkü 3.0 metni ise bambaşka. Bu yüzden kontrolü CI'a koyuyoruz. nuget-license, bir solution'daki paketlerin lisansını NuGet metadata'sından okuyan bir .NET tool'u; global değil, repo'nun tool manifest'ine local tool olarak kuruyoruz:

$ dotnet new tool-manifest
$ dotnet tool install nuget-license --version 4.0.18
#!/usr/bin/env bash
# eng/allowed-licenses.json dışındaki bir lisansta sıfırdan farklı exit code döner
set -euo pipefail
cd "$(dirname "$0")/.."
dotnet tool restore
dotnet nuget-license -i Bookstore.slnx -t \
    -a eng/allowed-licenses.json \
    -override eng/license-overrides.json

eng/allowed-licenses.json bugün yalnız ["MIT", "Apache-2.0", "BSD-3-Clause"] içeriyor. Bookstore'un mevcut hâlinde transitive paketlerle birlikte 49 paketin hepsi bu listede ve script sıfırla çıkıyor. Aday kütüphaneleri ayrı bir deneme projesine ekleyip aynı aracı çalıştırdığımızda tablo değişiyor (çıktıyı JSON alıp jq ile dört sütuna indirdik):

$ dotnet nuget-license -i LicenseProbe.csproj -a "MIT;Apache-2.0" -o Json | jq -r '...'
ClosedXML             0.105.1   MIT
EPPlus                8.7.2     PolyForm-Noncommercial-1.0.0  License "PolyForm-Noncommercial-1.0.0" not found in list of supported licenses
MailKit               5.0.0     MIT
PdfPig                0.1.16    Apache-2.0
QuestPDF              2026.9.2  LICENSE.md                    Unable to determine license from the given license file
SixLabors.ImageSharp  4.1.3     LICENSE                       Unable to determine license from the given license file
SkiaSharp             4.156.1   MIT
$ echo $?
3

Exit code, kurala uymayan paket sayısı. QuestPDF ve ImageSharp lisansını SPDX (lisansları standart kısa adlarla tanımlayan liste) ifadesi yerine pakete gömülü bir dosya olarak veriyor; araç bu dosyayı yorumlayamıyor ve bu doğru bir davranış. Gelir eşiğine bağlı bir lisansı bir araç senin yerine onaylayamaz. Kararı bir insan veriyor ve kayda geçiriyor:

[
  { "Id": "QuestPDF", "Version": "2026.9.2", "License": "LicenseRef-QuestPDF-Community" }
]

Override dosyası sürüme bağlı. QuestPDF'i bir sonraki sürüme yükselttiğinde override eşleşmez, kontrol yeniden kırmızıya döner ve birisi yeni sürümün lisans metnine tekrar bakmak zorunda kalır. Lisansın değişebildiği kütüphanelerde istediğimiz davranış tam olarak bu. LicenseRef-QuestPDF-Community adını izin listesine eklemek de ayrı bir karar: o satırı ekleyen pull request, şirketin Community License koşullarına uyduğunu kimin kontrol ettiğini de söylemeli.

Üretimde Dikkat

Bellekteki queue ve durum sözlüğü, uygulama yeniden başlayınca içindekileri kaybeder. Deploy sırasında queue'da bekleyen raporlar sessizce yok olur. Kalıcılık gerekiyorsa Hangfire gibi bir job altyapısı ya da veritabanında bir outbox tablosu kullan; mekaniği Background Job'lar ve Message Queue'larla Çalışmak serilerinde anlatıldı, bu serinin üçüncü ve dokuzuncu yazılarında uyguluyoruz.

Birden fazla API instance'ı varsa durum bilgisi de paylaşılmalı. Load balancer, POST isteğini A instance'ına, durum sorgusunu B instance'ına gönderebilir; bellekteki sözlükte job'u bulamayan B instance'ı 404 döner.

Senkron yolda süreyi ölç ve bir sınır koy. Request timeout'u yalnız iptal sinyali verir; renderer'ın CancellationToken'ı gerçekten dinlemesi gerekir. Kütüphane token kabul etmiyorsa (bazı PDF ve Excel kütüphanelerinin senkron API'leri gibi), işi request içinde çalıştırmanın riski artar.

Asenkron yolda kullanıcıya bir dönüş kanalı ver: durum endpoint'i, e-posta ya da Real-time serisinde anlatılan SignalR bildirimi. Dosyayı indirme endpoint'i de en az rapor kadar korunmalı. Bu örnekte job id'si tahmin edilmesi zor bir Guid, ama gerçek uygulamada dosyayı isteyen kullanıcının yetkisini de kontrol etmek gerekiyor.

Lisans kontrolünü pull request pipeline'ına koy, release pipeline'ına değil. Lisans sorununu bir yayının ortasında değil, paketin eklendiği gün öğrenmek istersin.

Sık Düşülen Tuzaklar

Kütüphaneyi lisansını okumadan seçmek. NuGet'te en çok indirilen paket senin için ücretsiz olmayabilir; QuestPDF, EPPlus ve ImageSharp'ın üçü de ticari kullanımı belli koşullara bağlıyor.

PDF üretimini web request'inde 30 saniye bekletmek. Kullanıcı sayfayı yeniler, proxy timeout verir ve sunucu yarım kalan işi kimse beklemediği hâlde bitirmeye çalışır.

Doküman üretimini doğrudan controller'a yazmak. Kütüphane değiştiğinde ya da iş job'a taşındığında her endpoint'i tek tek değiştirirsin; testte de gerçek SMTP sunucusuna ve diske ihtiyaç duyarsın.

Background işi Task.Run ile request'ten koparmak. Uygulama kapanırken iş yarıda kalır ve hata kimseye ulaşmaz. Hosted service ya da kalıcı bir job altyapısı kullan.

Release build'i yalnız CI'da denemek. ImageSharp 4 örneğinde olduğu gibi, Debug'da uyarı olan bir şey Release'te hata olabilir.

Küçük Bir Deneme

★☆☆ Reports:SimulatedRenderTime değerini 00:00:03 yap ve senkron rapor endpoint'ini tekrar çağır. Kaç saniyede, hangi status code ile yanıt alıyorsun? Değeri 00:00:06'ya çıkarınca ne değişiyor?

★★☆ PlainTextSalesReportRenderer içindeki Task.Delay'e CancellationToken geçme ve senkron endpoint'i tekrar dene. İstemci 504'ü yine 5. saniyede mi alıyor, yoksa 8. saniyede mi? Sunucu tarafında iş ne zaman bitiyor?

★★★ Aday paketlerden birini (ör. EPPlus) Bookstore.Notifications'a ekle ve eng/check-licenses.sh'yi çalıştır. Script'i GitHub Actions'ta pull request'lerde çalışan bir job'a dönüştür ve override dosyasındaki her kayıt için kararı veren kişiyi ve tarihi tutan bir alan ekle.

Bir sonraki yazıda LoggingEmailSender'ı emekliye ayırıyoruz: MailKit ile SMTP üzerinden gerçek bir sipariş onay e-postası gönderiyor, TLS (bağlantıyı şifreleyen protokol) ve port seçeneklerini deniyor ve gönderilen e-postayı local bir Mailpit sunucusunda kontrol ediyoruz.