Blazor'u Production'a Taşımak: WASM Boyutu, Trimming, AOT, Circuit Kaynakları, Reconnection UI ve Hosting
Blazor'u production'a taşırken iki maliyet ölçülür: WebAssembly'de ilk ziyarette inen veri (Bookstore panelinde 2,3 MB, AOT ile 4,3 MB ama CPU işinde yaklaşık altı kat hızlı) ve Server'da circuit başına bellek (stok sayfasında yaklaşık 800 KB, kapanan sekme üç dakika bellekte). Yazıda trimming ve relinking, fingerprint'li cache, CircuitOptions, ReconnectModal, nonce'lu Content Security Policy ve Nginx arkasında WebSocket ayarı gerçek ölçümlerle.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15) ile derlenip denenmiştir; WebAssembly derleme araçları için wasm-tools workload'u (10.0.15), ölçümler için dotnet-counters 10.0, reverse proxy için Nginx 1.30 kullanılmış, tarayıcı davranışı Playwright 1.65'in headless Chromium'uyla gözlenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yönetim paneli on bir yazıdır geliştirme makinesinde çalışıyor. Bu yazıda onu production'a hazırlıyoruz: WebAssembly tarafının ne kadar indiğini ölçüyor, InteractiveServer circuit'lerinin sunucuya maliyetini görüyor, yeniden bağlanma ekranını, Content Security Policy'yi ve reverse proxy'yi deniyoruz. Önce üç soru. AOT (Ahead-of-Time; .NET kodunun yayından önce doğrudan WebAssembly'ye derlenmesi) indirme boyutunu ne kadar büyütür, CPU ağırlıklı bir işi ne kadar hızlandırır? Kullanıcı sekmeyi kapattığında circuit'i ne zaman bellekten çıkar? Nginx'te WebSocket header'larını unutursan panel çalışmaz mı, yoksa başka bir şey mi olur?
WebAssembly'nin İndirme Boyutu
Boyut ancak Release publish'te anlamlı; Debug build'de trimming yok, dosyalar sıkıştırılmamış. Paneli üç şekilde publish ettim: düz SDK ile, wasm-tools workload'u kurulu SDK ile ve AOT ile:
$ dotnet publish src/Bookstore.Admin -c Release -o publish/il
$ dotnet workload install wasm-tools
$ dotnet publish src/Bookstore.Admin -c Release -o publish/relink
$ dotnet publish src/Bookstore.Admin -c Release -o publish/aot -p:RunAOTCompilation=true
_framework klasörünün diskteki hâli:
publish files on disk .br total dotnet.native.wasm (.br)
IL 53 9.4 MB 2.8 MB 2.9 MB (954 KB)
IL + relink 53 9.3 MB 2.8 MB 2.8 MB (909 KB)
AOT 53 20.2 MB 4.7 MB 13.6 MB (3182 KB)
Yaklaşık 9 MB'lık klasörün Brotli ile sıkıştırılmış toplamı 2,8 MB. Assembly'ler .NET 8'den beri Webcil biçiminde .wasm uzantılı geliyor; publish IL (Intermediate Language; .NET'in derleyici çıktısı olan ara kod) trimming'i varsayılan olarak yapıyor, ama granularity partial: yalnız framework library'leri ve trimming'e açıkça uygun olduğunu söyleyen library'ler kırpılıyor. Workload kuruluyken Release publish runtime'ı da yeniden link ediyor ve kullanılmayan runtime kodunu atıyor; dotnet.native.wasm 954 KB'tan 909 KB'a indi. Kazanç küçük, çünkü panel globalization verisini de kullanıyor; uygulamanın kültüre bağlı biçimlendirmeye ihtiyacı yoksa InvariantGlobalization ile bu veriler hiç gönderilmiyor.
Diskteki toplam kullanıcının indirdiği şey değil; tarayıcı yalnız boot'un istediği dosyaları alıyor. Boş cache'li bir tarayıcıyla WebAssembly component'i olan bir sayfayı açtım:
il: _framework 50 files, 2367 KB on the wire, WebAssembly interactive after 282 ms
Server runs 30, 19, 22, 21, 22 ms, median 22 ms
WebAssembly runs 98, 89, 92, 90, 91 ms, median 91 ms
relink: _framework 50 files, 2336 KB on the wire, WebAssembly interactive after 274 ms
Server runs 32, 21, 20, 20, 20 ms, median 20 ms
WebAssembly runs 103, 105, 100, 100, 104 ms, median 103 ms
aot: _framework 50 files, 4357 KB on the wire, WebAssembly interactive after 374 ms
Server runs 32, 20, 22, 23, 19 ms, median 22 ms
WebAssembly runs 24, 14, 15, 16, 17 ms, median 16 ms
İlk ziyarette 50 dosya, 2,3 MB; 01'de 2,2 MB ölçmüştük, aradaki yazılarda eklenen kodla biraz büyüdü. İkinci ziyarette bu dosyalar fingerprint'li adlarıyla cache'ten geliyor. Sayfadaki süreler yerel makinede, ağ gecikmesi olmadan ölçüldü; gerçek kullanıcıda asıl maliyet indirme süresi.
AOT: Boyut mu, Hız mı?
AOT'suz WebAssembly'de .NET kodu tarayıcıda bir IL yorumlayıcısında çalışıyor; doküman buna sık çalışan kodu kısmen JIT (Just-in-Time; çalışma anında makine koduna derleme) ile hızlandıran "Jiterpreter" diyor. AOT kodu publish sırasında WebAssembly'ye derliyor. Farkı ölçmek için yalnız CPU kullanan bir diagnostics component'i yazdım: iki milyon ISBN (International Standard Book Number; kitabın uluslararası numarası) için ISBN-13 kontrol hanesi hesabı:
// Yalnız CPU işi: I/O yok, JS interop yok; ölçülen süre .NET kodunun kendisi.
private void Run()
{
Stopwatch watch = Stopwatch.StartNew();
long checksum = 0;
for (long first12 = 978_000_000_000; first12 < 978_000_000_000 + Count; first12++)
{
checksum += CheckDigit(first12);
}
watch.Stop();
result = string.Create(CultureInfo.InvariantCulture,
$"Run {++runs}: {Count:N0} ISBN-13 check digits in {watch.ElapsedMilliseconds} ms (checksum {checksum})");
}
// Ağırlıklar soldan 1 ve 3 diye değişir; ilk 12 hanenin en sağdaki hanesinin ağırlığı 3.
private static int CheckDigit(long first12)
{
int total = 0;
for (int i = 0; i < 12; i++)
{
total += (int)(first12 % 10) * (i % 2 == 0 ? 3 : 1);
first12 /= 10;
}
return (10 - total % 10) % 10;
}
Aynı component sayfada bir kez InteractiveServer, bir kez InteractiveWebAssembly olarak duruyor; yukarıdaki çıktıda her biri beş kez çalıştı. İlk sorunun cevabı: AOT ilk ziyarette inen veriyi 2,3 MB'tan 4,3 MB'a çıkardı, aynı işi 91 ms'den 16 ms'ye indirdi. Doküman AOT'lu uygulamaların çoğunun yaklaşık iki kat büyük olduğunu söylüyor; burada dotnet.native.wasm tek başına 13,6 MB oldu, çünkü derlenmiş kod artık orada. Sunucudaki .NET'in JIT'i aynı işi 20 ms civarında yapıyor; AOT WebAssembly'yi sunucu hızına yaklaştırıyor.
Panel için karar AOT'suz kalmak: sayfaların işi veri göstermek ve form doldurmak, CPU'ya yüklenen bir hesap yok, ilk açılış ise her yeni personelde ödeniyor. Tarayıcıda resim işleme, büyük veri setinde filtreleme ya da hesaplama yapan bir uygulamada ölçüm tersini söyleyebilir. AOT yalnız publish'te çalışıyor ve süresi uzun; bu projede publish 29 saniyeden 53 saniyeye çıktı. Bu yüzden AOT'u csproj'a sabitlemek yerine pipeline'da -p:RunAOTCompilation=true ile açmak, workload'u olmayan geliştiricinin build'ini de bozmuyor. Lazy loading ise kendi yazdığın büyük assembly'leri ancak gerektiklerinde indirmek için; framework assembly'lerine uygulanmıyor.
Fingerprint, Compression ve Cache
MapStaticAssets publish sırasında üretilen .br ve .gz dosyalarını tarayıcının Accept-Encoding'ine göre sunuyor ve fingerprint'li adlara uzun süreli cache header'ı veriyor. Production ortamında iki istek:
$ curl -sD - -o /dev/null -H "Accept-Encoding: br, gzip" http://localhost:5037/_framework/blazor.web.whefm3rrpu.js
HTTP/1.1 200 OK
Content-Length: 47542
Cache-Control: max-age=31536000, immutable
Content-Encoding: br
$ curl -sD - -o /dev/null -H "Accept-Encoding: br, gzip" http://localhost:5037/_framework/blazor.web.js
HTTP/1.1 200 OK
Content-Length: 47542
Cache-Control: no-cache
Content-Encoding: br
Sayfa fingerprint'li adı kullanıyor (@Assets["..."], ImportMap); dosya değişince ad değiştiği için bir yıllık immutable cache güvenli. Fingerprint'siz ad yine çalışıyor ama her seferinde doğrulama istiyor. .NET 10'da boot configuration'ı ayrı bir blazor.boot.json dosyası olmaktan çıktı, dotnet.js'in içine gömüldü; App.razor'daki ResourcePreloader component'i de WebAssembly dosyalarını sayfa açılırken önceden yüklemeye başlatıyor. Statik dosyaları bir CDN'e (Content Delivery Network; dosyaları kullanıcıya yakın sunuculardan dağıtan ağ) koyacaksan aynı ad ve header'ların CDN'de de korunması gerekiyor.
Circuit Başına Bellek ve Kapanan Sekmeler
InteractiveServer'da her açık sekme sunucuda bir circuit: render tree, component'ler, circuit scope'undaki service'ler ve SignalR bağlantısı. Doküman bellek hesabını aktif ve disconnected circuit sayısı çarpı circuit başına bellek olarak veriyor. Panelin Release build'ini çalıştırıp stok sayfasını 20 ayrı tarayıcı context'inde açtım; GC (garbage collector; kullanılmayan belleği geri toplayan mekanizma) heap'ini tam bir collection'dan sonra ölçen, yalnız bu denemeye eklenmiş bir endpoint'le:
40 s no circuits: GC heap 36904 KB
45 s 20 tabs open: GC heap 53431 KB, about 826 KB per circuit
45 s 20 tabs closed
90 s 45 s later: GC heap 35684 KB
Stok sayfası için circuit başına yaklaşık 800 KB. Rakam sayfaya göre değişir; 08'de Virtualize'sız 10.000 satırlık listenin tek bir render batch'inin 3,1 MB olduğunu görmüştük. Bin eş zamanlı personel bu sayfada 800 MB demek. Aynı denemede dotnet-counters'la circuit sayılarını izledim:
$ dotnet-counters collect -p <pid> --counters Microsoft.AspNetCore.Components.Server.Circuits --refresh-interval 5 --format csv
(the CSV condensed; samples every 5 s, repeated rows left out)
time circuit.connected circuit.active
+0 s 20 20
+5 s 0 20
+30 s 0 20
+35 s 0 0
İkinci sorunun cevabı burada. Sekmeler kapanınca connected sıfıra iniyor ama active 20'de kalıyor: circuit'ler disconnected olarak bekliyor, çünkü kullanıcı kısa bir kopmadan sonra dönebilir. Bu denemede bekleme süresini 30 saniye yaptım; varsayılan üç dakika ve en fazla 100 disconnected circuit tutuluyor. 06'da kapanan sekmenin timer'ının üç dakika boyunca log yazmaya devam etmesi bundandı. Panelde bu değerler artık configuration'dan geliyor:
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents()
.AddAuthenticationStateSerialization();
// Circuit sınırları configuration'dan gelir (Blazor:Circuits); build almadan ortam başına ayarlanabilir.
builder.Services.Configure<CircuitOptions>(builder.Configuration.GetSection("Blazor:Circuits"));
"Blazor": {
"Circuits": {
"DisconnectedCircuitMaxRetained": 100,
"DisconnectedCircuitRetentionPeriod": "00:03:00",
"JSInteropDefaultCallTimeout": "00:01:00"
}
},
Değerler şimdilik varsayılanlarla aynı ama artık görünür ve ortam başına değiştirilebilir. Az bellekli bir sunucuda süreyi ve sayıyı düşürmek belleği hızlı boşaltır, karşılığında bağlantısı kısa süre kopan kullanıcı yeni bir circuit'le baştan başlar. JSInteropDefaultCallTimeout 10'da konuştuğumuz bir dakikalık JS çağrısı sınırı.
.NET 10, Blazor için Microsoft.AspNetCore.Components, Microsoft.AspNetCore.Components.Lifecycle ve Microsoft.AspNetCore.Components.Server.Circuits meter'larını ve circuit başlatma, navigation ve event handling için activity'ler getirdi; yukarıdaki circuit.active ve circuit.connected bunlardan. Bir ayrıntı: dotnet-counters bu UpDownCounter'ları dinlemeye başladığı andan itibaren gördüğü artış ve azalışlarla gösteriyor. Oturumu circuit'ler açıkken başlatınca değerler eksiye düştü; doğru rakam için uygulama başlarken bağlanan bir OpenTelemetry exporter'ı gerekiyor. Bunun kurulumu Observability serisinin konusu.
ReconnectModal ve Yeniden Bağlanma
.NET 10 template'i yeniden bağlanma arayüzünü uygulamanın içine bir component olarak koyuyor: ReconnectModal.razor, collocated CSS ve JS dosyası. Blazor durum değiştikçe dialog element'ine components-reconnect-state-changed event'ini gönderiyor; template'in module'ü buna göre davranıyor:
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
Stok sayfası açıkken sunucu process'ini durdurdum ve on iki saniye sonra yeniden başlattım:
0.2 s inventory page open, circuit connected
1.7 s server process stopped
1.7 s components-reconnect-state-changed: {"state":"show"}
1.7 s components-reconnect-state-changed: {"state":"retrying","currentAttempt":1,"secondsToNextAttempt":0}
13.7 s dialog open: true, text: "Rejoining the server..."
13.7 s starting the server again (new process, new memory)
21.8 s ... 33 more "retrying" events, the last one: {"state":"retrying","currentAttempt":14,"secondsToNextAttempt":0}
21.8 s components-reconnect-state-changed: {"state":"rejected"}
21.9 s page load /inventory
42.9 s now on /inventory, rows: 20
Önce show, sonra her denemede bir retrying geldi; dialog "Rejoining the server..." gösterdi. Yeni process eski circuit'i tanımadığı için rejected geldi ve module sayfayı yeniledi. Kullanıcı yeniden giriş yapmadı, çünkü cookie hâlâ geçerliydi; ama formda yazdıkları ve sayfanın bellekteki state'i gitti. 06'daki circuit state persistence bellekte tutulduğu için process yeniden başlayınca o da kayboluyor; deploy sırasında state'i korumak ve birden fazla sunucuda başka bir sunucuda devam edebilmek için doküman persisted circuit state'in Redis gibi dağıtık bir store'u olan bir HybridCache'e yazılmasını anlatıyor.
Content Security Policy
10'da inline event handler'ların CSP (Content Security Policy; tarayıcıya hangi script, stil ve bağlantılara izin verildiğini söyleyen header) ile engellenebileceğini söylemiştik. Panelde artık bir CSP var; development dışında açık ve her response için yeni bir nonce üretiyor:
public static IApplicationBuilder UseContentSecurityPolicy(this IApplicationBuilder app) => app.Use(async (context, next) =>
{
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
context.Items[NonceKey] = nonce;
context.Response.Headers.ContentSecurityPolicy = string.Join("; ",
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"img-src 'self' data:",
$"script-src 'self' 'wasm-unsafe-eval' 'nonce-{nonce}' 'unsafe-hashes' {NavMenuToggleHash}",
"style-src 'self'",
// Virtualize boşluk element'lerinin boyunu style attribute'larıyla verir; bu attribute'lara izin verir, <style> bloklarına değil.
"style-src-attr 'unsafe-inline'",
"connect-src 'self'");
await next();
});
wasm-unsafe-eval WebAssembly runtime'ı için gerekiyor. ImportMap sayfaya inline bir <script type="importmap"> yazıyor; içeriği fingerprint'lerle değiştiği için hash yerine nonce'la izin veriyoruz, App.razor da nonce'u component'e veriyor:
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
// UseContentSecurityPolicy set eder; inline import map'e bu nonce ile izin verilir.
private string? Nonce => HttpContext?.Items[Bookstore.Admin.Security.ContentSecurityPolicy.NonceKey] as string;
}
Template'in NavMenu'sündeki inline onclick dokümandaki hash'le açılıyor. İlk denemede style-src 'self' vardı ve stil attribute'larına izin vermiyordu. Sayfaları gezdim, sonra uzun listenin Virtualize'ını kaydırdım:
scrollHeight 858px, last rendered row after scrolling to the end: 26, CSP violations: 5
Kendi style="..." attribute'larımızı scoped CSS'e taşıyınca 42 ihlal 5'e indi; kalan beşi Virtualize'ın boşluk element'lerine verdiği yükseklikler. Bunlar engellenince liste 10.000 satır yerine ilk 26 satırda kalıyor. Blazor bu attribute'ları DOM'a (Document Object Model; tarayıcının sayfayı tuttuğu ağaç yapısı) kendisi yazdığı için tek yol style-src-attr 'unsafe-inline': stil attribute'larına izin veriyor, <style> bloklarına vermiyor. Sonuç:
scrollHeight 330000px, last rendered row after scrolling to the end: 10000, CSP violations: 0
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; img-src 'self' data:; script-src 'self' 'wasm-unsafe-eval' 'nonce-…' 'unsafe-hashes' 'sha256-qnHnQs7NjQNHHNYv/I9cW+I62HzDJjbnyS/OFzqlix0='; style-src 'self'; style-src-attr 'unsafe-inline'; connect-src 'self', frame-ancestors 'none'
/inventory: 0 violation(s)
/books/9780141439587: 0 violation(s)
/reports/sales: 0 violation(s)
/diagnostics/claims: 0 violation(s)
/diagnostics/long-list: 0 violation(s)
/authors: 0 violation(s)
Header'ın sonundaki frame-ancestors 'none' ayrı bir header'dan geliyor: Blazor interaktif server render'da kendisi frame-ancestors 'self' ekliyor. Panelin bir iframe içinde açılmasına gerek olmadığı için bunu ContentSecurityFrameAncestorsPolicy ile 'none' yaptık. Chart.js, Bootstrap ve ReconnectModal bu policy'yle ihlal üretmedi; Chart.js stilleri CSSOM (CSS Object Model; stillerin JavaScript'ten nesne olarak değiştirilmesi) ile verdiği için CSP onu engellemiyor.
Reverse Proxy Arkasında
Production'da Kestrel'in önünde genellikle bir reverse proxy duruyor. Circuit uzun ömürlü bir WebSocket bağlantısı olduğu için proxy'nin Upgrade ve Connection header'larını geçirmesi gerekiyor. Snapshot'taki örnek Nginx ayarı:
# Nginx arkasında Bookstore.Admin. Upgrade/Connection header'ları Blazor circuit'inin WebSocket kullanmasını sağlar;
# onlar olmadan SignalR long polling'e düşer. TLS sonlandırma bu örneğe alınmadı.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name admin.bookstore.example;
location / {
proxy_pass http://127.0.0.1:5037;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# $http_host portu korur; $host portu düşürür ve panelin yönlendirmeleri yanlış adrese gider.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Circuit uzun ömürlü bir bağlantıdır; SignalR'ın 15 saniyede bir gönderdiği keep-alive ping'leri onu bu sürenin altında tutar.
proxy_read_timeout 100s;
proxy_buffering off;
}
}
Bunu bir Nginx container'ında, 8080 portundan panele yönlendirerek denedim; bir kez bu header'larla, bir kez onlarsız:
with Upgrade/Connection: WebSockets opened: /_blazor; long-polling GETs in 5 s: 0
console: Information: WebSocket connected to ws://localhost:8080/_blazor?id=….
without them: WebSockets opened: /_blazor; long-polling GETs in 5 s: 7
console: WebSocket connection to 'ws://localhost:8080/_blazor?id=… failed: Error during WebSocket handshake: Unexpected response code: 200
console: Information: (WebSockets transport) There was an error with the transport.
console: Error: Failed to start the transport 'WebSockets': Error: WebSocket failed to connect. The connection could not be found on the server, either the endpoint may not be a SignalR endpoint, the connection ID is not present on the server, or there is a proxy blocking WebSockets. If you have multiple servers check that sticky sessions are enabled.
console: Warning: Failed to connect via WebSockets, using the Long Polling fallback transport. This may be due to a VPN or proxy blocking the connection. To troubleshoot this, visit https://aka.ms/blazor-server-using-fallback-long-polling.
Üçüncü sorunun cevabı: panel çalışmaya devam ediyor ama WebSocket handshake'i başarısız olunca SignalR long polling'e düşüyor; beş saniyede yedi HTTP isteği, her etkileşimde ek gecikme ve sunucuda daha fazla iş. Kullanıcı bir şey fark etmeyebilir, bu yüzden konsoldaki uyarıyı ya da sunucu tarafındaki transport'u izlemek gerekiyor. İlk denemede Host header'ında $host vardı:
$ curl -i http://localhost:8080/inventory
HTTP/1.1 302 Found
Location: http://localhost/account/login?ReturnUrl=%2Finventory
$host portu düşürüyor; standart 80/443 portlarında sorun çıkmaz ama başka bir portta login yönlendirmesi yanlış adrese gidiyor. TLS (Transport Layer Security; HTTPS'in şifreleme katmanı) proxy'de sonlandırılıyorsa uygulamanın gerçek şemayı ve istemci IP'sini görmesi için forwarded header'ları açman gerekiyor; ayrıntısı Web Server'lar ve Hosting serisinin reverse proxy yazısında. Birden fazla sunucuda bir kullanıcının bağlantısı her zaman aynı sunucuya gitmeli (sticky session), çünkü circuit o sunucunun belleğinde; Azure SignalR Service bağlantıları kendisi tutup kullanıcıyı aynı sunucuya yönlendirerek bunu çözüyor. Scale-out Real-time Uygulamalar serisinin scale-out yazısında anlatıldı; bu yazıda Azure tarafı çalıştırılmadı. Yalnız WebAssembly kullanan standalone bir uygulama ise sunucu gerektirmiyor; wwwroot çıktısı herhangi bir statik host'a konabiliyor.
Üretimde Dikkat
09'da panelin veritabanına yalnız okumak için bağlandığını söylemiştik; production'da bunu yetkiyle de garanti etmek gerekiyor. 11'den beri panel kendi identity schema'sına da yazıyor. SQL Server'da kabaca şöyle (bu betik bu yazıda çalıştırılmadı):
CREATE USER bookstore_admin FOR LOGIN bookstore_admin;
GRANT SELECT ON SCHEMA::catalog TO bookstore_admin;
GRANT SELECT ON SCHEMA::inventory TO bookstore_admin;
GRANT SELECT ON SCHEMA::ordering TO bookstore_admin;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::identity TO bookstore_admin;
Migration'ları uygulama kullanıcısı değil, deploy adımı ayrı ve yetkili bir kullanıcıyla çalıştırmalı; development'taki otomatik migration production'da kapalı.
07'deki yazar ekleme endpoint'i ISBN'leri önce sorguluyor, sonra kaydediyor. İki istek aynı anda gelirse ikisi de kontrolü geçebilir ve unique index ikincisini durdurur; API bu durumda 500 yerine aynı validation hatasını dönmeli. 10'daki 32 KB'lık MaximumReceiveMessageSize sınırı production'da da yerinde kalmalı. Persist edilen state de bir maliyet: 06'da 20 satırlık stok listesinin prerender sırasında sayfaya gömülen state'i 4.656 karakterdi ve her açılışta HTML'e ekleniyor.
Sık Düşülen Tuzaklar
Boyutu Debug build'de ölçmek. Trimming, sıkıştırma ve relinking yalnız Release publish'te var.
AOT'u ölçmeden açmak. İlk açılış iki kat ağırlaşıyor; kazanç yalnız CPU ağırlıklı kodda.
Disconnected circuit'leri unutmak. Kapanan sekme süre dolana kadar bellekte; bellek hesabına onlar da girer.
CSP'yi uygulamadan önce bütün sayfalarda denememek. Virtualize gibi kırılmalar hata vermeden görünür; konsoldaki ihlaller tek ipucu.
Proxy'de WebSocket header'larını unutmak. Panel long polling'le çalışmaya devam ettiği için sorun ancak ölçünce görünür.
Birden fazla sunucuyu sticky session'sız çalıştırmak. Bağlantı başka sunucuya giderse circuit bulunamaz ve kullanıcı sayfayı yeniden yükler.
Küçük Bir Deneme
★☆☆ Client projesine InvariantGlobalization ekleyip publish et. _framework ne kadar küçülüyor, panelde tutarların biçimi değişiyor mu?
★★☆ Yazar ekleme endpoint'indeki yarışı bir testle göster: kayıttan hemen önce aynı ISBN'li bir kitabı veritabanına ekleyen bir SaveChangesInterceptor kullan, sonra DbUpdateException'ı yakalayıp 400 dönen düzeltmeyi yaz.
★★★ Panelin iki kopyasını Nginx'in arkasında upstream ile çalıştır. Sticky session olmadan ve ip_hash ile circuit'e ne oluyor?
Böylece paneli production'a taşıdık. Serinin son yazısında geriye bakıyoruz: Blazor'da .NET 8'den .NET 11'e neler değişti, panel .NET 11'e yükseltildiğinde ne oluyor ve yükseltme için bir kontrol listesi.