Mert Özen Her Satırda Daha İleri
Veritabanı Elasticsearch ile Arama — #10

.NET'te Elastic.Clients.Elasticsearch 9: Typed API, Fluent ve Object Initializer, Serialization, Hata Yönetimi ve Test

Mert Özen 14 Kas 2026 13 dk 13 görüntülenme
.NET'te Elastic.Clients.Elasticsearch 9: Typed API, Fluent ve Object Initializer, Serialization, Hata Yönetimi ve Test

Elastic.Clients.Elasticsearch 9'da client'ı DI'da singleton ve doğrulanmış options ile kaydeder, sorguları test edilebilir bir builder'da toplar ve davranışı Testcontainers ile gerçek bir Elasticsearch'e karşı test edersin. Gerçek çıktılarla descriptor'lı aramanın index'i atlayıp /_search'e gitmesi, source serializer'da Add yerine Insert, server, transport ve 429 hataları, ES|QL'i metin ve LINQ ile çağırmak ve 9. yazıdaki hatayı yakalayan regresyon testi.

Sürüm notu: Bu yazıdaki örnekler Elasticsearch 9.5.6, .NET 10, Elastic.Clients.Elasticsearch 9.5.3 (Elastic.Transport 1.1.0 ve Elastic.Esql 0.11.0 ile birlikte gelir), Testcontainers.Elasticsearch 4.16.0 ve xunit.v3 4.0.3 ile, 2. yazıda kurduğumuz lab'da çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için yazdığımız arama kodu iki farklı sözdizimiyle sorgu kuruyor: 6. yazıdaki endpoint fluent (lambda'lı) sözdizimini, 7. yazıdan sonrası object initializer'ı kullandı. Lab'a eklediğimiz syntax komutu aynı sorguyu iki şekilde yazıp gönderiyor:

// Fluent: lambda'lar ve descriptor'lar, JSON'un şekline yakın.
SearchResponse<Book> fluent = await client.SearchAsync<Book>(s => s
    .Size(5)
    .Query(q => q.Bool(b => b
        .Must(m => m.Match(mt => mt.Field(f => f.Title).Query("dune")))
        .Filter(f => f.Term(t => t.Field(x => x.Language).Value("tr"))))));

// Object initializer: adım adım kurulabilen, saklanabilen ve test edilebilen düz nesneler.
SearchRequest<Book> request = new()
{
    Size = 5,
    Query = new BoolQuery
    {
        Must = [new MatchQuery { Field = "title", Query = "dune" }],
        Filter = [new TermQuery { Field = "language", Value = "tr" }]
    }
};
SearchResponse<Book> initializer = await client.SearchAsync<Book>(request);

İki istek aynı JSON'u mu üretir? Aynı sonucu mu döndürür? Client'ın ayarlarında DefaultMappingFor<Book> ile Book'un index'inin books olduğunu söylemiştik; bunu unutma.

Client'ı Kaydetmek: Options, Doğrulama ve Debug Modu

Önce client'ın nasıl oluşturulduğunu düzenleyelim. 2. yazıdan beri API ve senkronizasyon worker'ı client'ı Program.cs'te elle kuruyordu. Artık Bookstore.Search'te tek bir extension method var:

public static IServiceCollection AddBookstoreSearch(this IServiceCollection services, IConfiguration configuration, bool isDevelopment)
{
    services.AddOptions<ElasticsearchOptions>()
        .Bind(configuration.GetSection(ElasticsearchOptions.SectionName))
        .Validate(o => !string.IsNullOrWhiteSpace(o.ApiKey), "Elasticsearch:ApiKey is empty.")
        .ValidateOnStart();

    // Singleton: client bağlantı havuzunu ve node durumunu tutar; istek başına oluşturma.
    services.AddSingleton<ElasticsearchClient>(provider =>
    {
        ElasticsearchOptions options = provider.GetRequiredService<IOptions<ElasticsearchOptions>>().Value;
        ILogger? debugLogger = isDevelopment
            ? provider.GetRequiredService<ILoggerFactory>().CreateLogger("Bookstore.Search.Elasticsearch")
            : null;
        return SearchClientFactory.Create(options, debugLogger);
    });

    return services;
}
// Options, doğrulama ve singleton client (10. yazı); debug modu yalnız Development'ta.
builder.Services.AddBookstoreSearch(builder.Configuration, builder.Environment.IsDevelopment());

ValidateOnStart, API key boşsa uygulamanın ilk istekte değil açılışta hata vermesini sağlıyor. ElasticsearchOptions'a bir de RequestTimeout eklendi; API'nin appsettings.json'ında 5 saniye. Client'ın varsayılan zaman aşımı bir arama endpoint'i için fazla uzun: Elasticsearch yavaşladığında istekler birikir ve thread'ler bekler. Worker'da ayar yok, toplu işler varsayılanla çalışıyor.

SearchClientFactory development'ta debug modunu açıyor:

if (debugLogger is not null)
{
    // Yalnız development: her isteğin ve cevabın gövdesini bellekte tutar ve loglar.
    settings.EnableDebugMode(details => debugLogger.LogDebug("{DebugInformation}", details.DebugInformation));
}
dbug: Bookstore.Search.Elasticsearch[0]
      Successful (200) low level call on POST: /books/_search?pretty=true&error_trace=true

      # Audit trail of this API call:
       - [1] HealthyResponse: Node: http://localhost:9200/ Took: 00:00:00.0352720
      # Request:
      {
        "collapse": {
          "field": "title.keyword"
        },
        "query": {
          "bool": {
            "filter": [],
      ...

Hata ayıklarken bundan iyisi yok: gönderilen JSON, gelen cevap, hangi node'a gidildiği. Log'da API key yok (kontrol ettim), ama kullanıcının arama metni ve dönen kitaplar var. Debug modu ayrıca her cevabı belleğe alıyor ve isteklere pretty=true ile error_trace=true ekliyor. Production'da açık kalması hem performans hem kişisel veri sorunu; bu yüzden ortam kontrolü kodda, bir config bayrağında değil.

Elastic Cloud kullanıyorsan ElasticsearchClientSettings'in Cloud ID alan constructor'ı URL yerine bu kimliği kabul ediyor; lab'da Elastic Cloud olmadığı için bunu çalıştırmadım.

Fluent ve Object Initializer

Tahminin cevabı:

dotnet run --project src/Bookstore.SearchLab -- syntax
Fluent:      POST /_search {"query":{"bool":{"filter":{"term":{"language":{"value":"tr"}}},"must":{"match":{"title":{"query":"dune"}}}}},"size":5} -> 1 hit(s)
Initializer: POST /books/_search {"query":{"bool":{"filter":{"term":{"language":{"value":"tr"}}},"must":{"match":{"title":{"query":"dune"}}}}},"size":5} -> 1 hit(s)
Same request body: True

Gövdeler birebir aynı ama adresler değil. SearchRequest<Book>, index'i DefaultMappingFor<Book>'dan alıp /books/_search'e gidiyor; lambda'lı SearchAsync<Book>(s => ...) ise 9.5.3'te index'i çıkarmıyor ve /_search'e, yani erişebildiği bütün index'lere gidiyor. GetAsync<Book> ise /books/_doc/...'ya gitti; tutarsızlık yalnız arama descriptor'ında. Bunu 9.0'ın breaking change listesinde açıkça göremedim, deneyerek buldum. Sonuç aynı çünkü uygulamanın API key'i yalnız books* index'lerini görüyor ve lab'da şu an başka bir books* index'i yok. Bir tane olunca fark hemen çıkıyor:

PUT /books-load/_doc/x1?refresh=true
{ "title": "Dune Messiah", "language": "tr" }

dotnet run --project src/Bookstore.SearchLab -- syntax
Fluent:      POST /_search {"query":{"bool":{"filter":{"term":{"language":{"value":"tr"}}},"must":{"match":{"title":{"query":"dune"}}}}},"size":5} -> 2 hit(s)
Initializer: POST /books/_search {"query":{"bool":{"filter":{"term":{"language":{"value":"tr"}}},"must":{"match":{"title":{"query":"dune"}}}}},"size":5} -> 1 hit(s)

DELETE /books-load

5. yazının books-load deneme index'ini 6. yazının başında silmemizin sebebi buydu: 4. yazıdaki search komutu ve 8. yazıdaki /books/suggest endpoint'i descriptor kullanıyordu ve o index dururken onun kitaplarını da getirecekti. İkisine de .Indices(SearchClientFactory.BooksIndex) ekledik. Kural: descriptor ile aradığın her yerde index'i açıkça ver; object initializer'da da SearchRequest<Book>'u, generic olmayan SearchRequest'e tercih et (o da /_search'e gidiyor). API key'i dar tutmak bu tür hataların etkisini sınırlıyor ama yerine geçmiyor.

Hangi sözdizimi? Fluent, Dev Tools'taki JSON'a benzediği için kısa ve okunur; 9.0'da descriptor'lar class'tan struct'a dönüştü ve sorgu türleri Query container'ında property olarak duruyor. Object initializer ise koşullara göre adım adım kurulan, bir değişkende saklanan ve test edilen sorgular için daha uygun. Bookstore'da kuralımız: Dev Tools'tan taşınan tek seferlik sorgular fluent, iş mantığı olan sorgular object initializer.

Sorguları Bir Builder'da Toplamak

9. yazının sonunda /books/search endpoint'i sorgu, post_filter, aggregation, sıralama, highlight ve suggester'ı kendi içinde kuruyordu. Bunların hepsini Bookstore.Search'te BookSearchRequestBuilder'a taşıdık; builder HTTP bilmiyor, client'a ihtiyaç duymuyor ve tek bir SearchRequest<Book> döndürüyor:

public static SearchRequest<Book> Build(BookSearchInput input, SearchTuning tuning)
{
    string? text = input.Filter.Text;
    bool hasText = !string.IsNullOrWhiteSpace(text);
    return new SearchRequest<Book>
    {
        From = (input.Page - 1) * input.PageSize,
        Size = input.PageSize,
        TrackTotalHits = new TrackHits(true),
        Query = BookSearchQuery.Build(new BookSearchFilter(text), tuning),
        PostFilter = BookFacets.PostFilter(input.Filter),
        Aggregations = BookFacets.Aggregations(input.Filter),
        Sort = BuildSort(input.Sort),
        Highlight = hasText ? BuildHighlight() : null,
        Suggest = hasText ? BuildDidYouMean(text!) : null
    };
}
BookSearchInput input = new(new BookSearchFilter(q, category, language, minPrice, maxPrice, minRating), sort, currentPage, size);
SearchRequest<Book> request = BookSearchRequestBuilder.Build(input, SearchTuning.Tuned);

SearchResponse<Book> response = await client.SearchAsync<Book>(request, cancellationToken);

Endpoint artık yalnız parametre doğrulama, çağrı ve cevap modeline dönüşüm yapıyor. Asıl kazanç test: builder'ın ürettiği isteği gerçek bir Elasticsearch'e gitmeden JSON'a çevirip kontrol edebiliyoruz (aşağıda).

Serialization: System.Text.Json ve Source Serializer

Client iki serializer kullanıyor: isteklerin ve cevapların kendisi için (sorgular, aggregation'lar) bir request/response serializer'ı, senin document'ların (_source) için bir source serializer. İkisi de System.Text.Json; ilkine dokunmamalısın, ikincisi senin tiplerin için ve ayarlanabilir. Varsayılan source serializer camelCase property adları kullanıyor, null'ları yazmıyor ve enum'ları adıyla yazıyor. Kitap formatı gibi bir enum'un index'te küçük harfle durmasını istediğimizi düşünelim:

// Document'lar (_source) System.Text.Json ile yazılır; enum'lar küçük harfli kebab case ("e-book").
// Add değil Insert: varsayılan ayarlarda zaten bir enum converter var ve ilk eşleşen converter kazanır.
public static Serializer CreateSourceSerializer(Serializer builtIn, IElasticsearchClientSettings settings) =>
    new DefaultSourceSerializer(settings, options => options.Converters.Insert(0, new JsonStringEnumConverter(JsonNamingPolicy.KebabCaseLower)));
dotnet run --project src/Bookstore.SearchLab -- serialization
Default source serializer:   {"isbn":"9781000000030","format":"EBook","price":165.75}
Bookstore source serializer: {"isbn":"9781000000030","format":"e-book","price":165.75}

Bu factory new ElasticsearchClientSettings(nodePool, CreateSourceSerializer) ile veriliyor. İlk denemem options.Converters.Add(...) idi ve çıktı değişmedi: varsayılan ayarlarda zaten bir JsonStringEnumConverter var, System.Text.Json listedeki ilk uygun converter'ı kullanıyor ve bizimki sona eklenince hiç devreye girmiyor. Bunun gibi bir ayarı her zaman serialize edip çıktıya bakarak doğrula. Source serializer'ı değiştirmenin bir bedeli var: index'teki mevcut document'lar eski biçimde kalır; enum değerlerinin yazılışını değiştirmek, keyword alanlarda filtreleri sessizce boşa düşürür ve reindex gerektirir.

Hata Yönetimi: IsValidResponse, Server ve Transport Hataları, 429

Client varsayılan olarak exception fırlatmıyor; her cevapta IsValidResponse'a bakmak gerekiyor. errors komutu üç farklı hatayı gösteriyor: olmayan bir index, dinlemeyen bir port ve 429 dönen küçük bir stub (gerçek bir cluster'da 429 üretmek zor; stub, Elasticsearch'ün hata gövdesini ve X-Elastic-Product header'ını döndüren 20 satırlık bir HttpListener):

dotnet run --project src/Bookstore.SearchLab -- errors
Missing index: IsValidResponse False, HTTP 404
  server error: index_not_found_exception no such index [books-missing]
  exception:    TransportException Request failed to execute. Call: Status code 404 from: POST /books-missing/_search. ServerError: Type: index_not_found_exception Reason: "no such index [books-missing]"
Unreachable node: IsValidResponse False, HTTP none
  server error:
  exception:    HttpRequestException Connection refused (localhost:9399)
429 from the cluster: IsValidResponse False, HTTP 429
  server error: es_rejected_execution_exception rejected execution of search
  exception:    TransportException Request failed to execute. Call: Status code 429 from: POST /_search. ServerError: Type: es_rejected_execution_exception Reason: "rejected execution of search"
  requests the stub received: 1

Üç bilgi kaynağı var: ElasticsearchServerError Elasticsearch'ün döndürdüğü hata (tip ve sebep), ApiCallDetails.HttpStatusCode HTTP durumu (bağlantı kurulamadıysa yok) ve ApiCallDetails.OriginalException transport seviyesindeki exception. 6. yazıdan beri endpoint'lerimiz bu üçünü logluyor ve 503 dönüyor. ThrowExceptions() ayarıyla client bu durumlarda TransportException fırlatabilir; Bookstore'da cevaba bakmayı tercih ediyoruz, çünkü 404 gibi bazı "hatalar" iş mantığının parçası.

Stub'ın tek istek alması önemli: client 429'u kendiliğinden tekrar denemiyor. 429, cluster'ın "şu an yetişemiyorum" demesi; Elasticsearch 9.0'dan itibaren zaman aşımına uğrayan işlemler de 5xx yerine 429 dönüyor (9.0 breaking change listesinde). Kararı iş tipine göre ver. Kullanıcıya dönük aramada tekrar denemek, zaten zorlanan cluster'a yük ekler; hızlıca 503 dön ve istemci bir süre sonra tekrar denesin. Toplu işlerde (5. yazıdaki senkronizasyon, ilk yükleme) artan bekleme süreleriyle tekrar dene; BulkAll'ın BackOffRetries ve BackOffTime ayarları bunu yapıyor. Retry'ın genel tasarımı, External Service Integration ve Resilience serisinin konusu.

ES|QL'i .NET'ten Çağırmak

9. yazıda Dev Tools'ta çalıştırdığımız ES|QL sorgularını client'tan iki şekilde çağırabiliyorsun: sorgu metniyle ya da LINQ (Language Integrated Query; C# içinde sorgu yazma sözdizimi) ile. Elastic.Clients.Elasticsearch 9.5.3, LINQ'yu ES|QL'e çeviren Elastic.Esql paketini (0.11.0) bağımlılık olarak getiriyor; paket hâlâ 0.x sürümünde, API'si değişebilir.

using ElasticsearchStreamResponse raw = await client.Esql.QueryAsync(q => q
    .Query("FROM books | WHERE language == \"tr\" | STATS books = COUNT(*), avg_price = AVG(price) BY categories | SORT books DESC | LIMIT 3"));
using JsonDocument result = await JsonDocument.ParseAsync(raw.Body);

await foreach (CheapBook book in client.Esql.QueryAsync<Book, CheapBook>(q => q
    .Where(b => b.Language == "tr" && b.Price < 170)
    .OrderBy(b => b.Price)
    .Take(3)
    .Select(b => new CheapBook(b.Title, b.Price))))
{
    Console.WriteLine($"  {book.Title}: {book.Price}");
}
dotnet run --project src/Bookstore.SearchLab -- esql
Raw ES|QL: IsValidResponse True, HTTP 200
  books | avg_price | categories
  21 | 275.9047619047619 | Fiction
  17 | 268.47058823529414 | Classics
  2 | 245.0 | Fantasy
  POST /_query {"columnar":false,"query":"FROM books\n| WHERE (language == "tr" AND price < 170)\n| SORT price\n| LIMIT 3\n| KEEP title, price"}
  Dokuzuncu Hariciye Koğuşu: 120.0
  Aylak Adam: 150.0
  Yaban: 155.0

Metinli çağrı cevabı bir stream olarak veriyor (ElasticsearchStreamResponse); JSON'u kendin okuyup columns ve values'u eşliyorsun. format=txt ile denediğimde HTTP 200 gelmesine rağmen IsValidResponse false ve gövde boştu; client'tan JSON iste. LINQ tarafında üretilen ES|QL'i debug client'ın OnRequestCompleted callback'iyle yazdırdım: index adı yine DefaultMappingFor<Book>'dan geliyor ve Select, KEEP title, price'a dönüşüyor. Projeksiyonu sonradan ekledim: ilk denemede doğrudan Book okudum ve The JSON value is not in a supported DateOnly format hatası aldım, çünkü ES|QL tarihleri saat bilgisiyle, ISO (Uluslararası Standartlar Örgütü) 8601 biçiminde döndürüyor. Rapor ve yönetim ekranları için yalnız gereken kolonları seçmek zaten doğru alışkanlık.

Testcontainers ile Integration Testi

Yeni tests/Bookstore.Search.Tests projesi iki tür test içeriyor. Birincisi builder'ın unit testleri: istek kurulup JSON'a çevriliyor, hiçbir şey gönderilmiyor.

[Fact]
public void Post_filter_is_omitted_when_no_filter_is_selected()
{
    JsonElement json = Build(new BookSearchFilter("roman"));

    // 9. yazı: null bir BoolQuery bir kez "post_filter": {} oldu ve her arama HTTP 400 ile düştü.
    Assert.False(json.TryGetProperty("post_filter", out _));
}

Bu test 9. yazıdaki hatanın regresyon testi. Düzeltmeyi geri alıp çalıştırdığımda:

dotnet test --project tests/Bookstore.Search.Tests -- --filter-class Bookstore.Search.Tests.BookSearchRequestBuilderTests
BookFacets.cs(51,16): warning CS8604: Possible null reference argument for parameter 'value' in 'Query.implicit operator Query(BoolQuery value)'.
failed Bookstore.Search.Tests.BookSearchRequestBuilderTests.Post_filter_is_omitted_when_no_filter_is_selected (0ms)
  Assert.False() Failure
  Expected: False
  Actual:   True
Test run summary: Failed!
  total: 5
  failed: 1
  succeeded: 4

Derleyici de uyarıyor: nullable analizi, null bir BoolQuery'nin implicit operator'e gittiğini görüyor. 9. yazıda yalnız hatalara baktığım için bu uyarıyı kaçırmıştım. Repo'nun köküne bir Directory.Build.props ekledik ve nullable uyarılarını hataya çevirdik (<WarningsAsErrors>nullable</WarningsAsErrors>); artık bu hata derlemeyi durdurur.

İkinci tür integration testleri. Mapping'deki analyzer'lar, ICU (International Components for Unicode; Unicode metin işleme kütüphanesi) plugin'i ve synonyms set gerçek bir Elasticsearch olmadan test edilemiyor; mock'lamak, test edilmek istenen şeyi ortadan kaldırır. Testcontainers her test çalışmasında lab'daki Dockerfile'dan image'ı build edip geçici bir container açıyor, search/synonyms.json, search/books.index.json ve data/books.json'ı yüklüyor:

_image = new ImageFromDockerfileBuilder()
    .WithDockerfileDirectory(Path.Combine(Root, "lab", "elasticsearch"))
    .WithBuildArgument("ES_VERSION", "9.5.6")
    .WithName("bookstore-search-es-test:9.5.6")
    .Build();
await _image.CreateAsync();

// TEST_CONTAINER_PREFIX, CI'da container'lara tanınabilir bir ad vermeyi sağlar.
string prefix = Environment.GetEnvironmentVariable("TEST_CONTAINER_PREFIX") ?? "bookstore-search-test";
_container = new ElasticsearchBuilder(_image)
    .WithName($"{prefix}-{Guid.NewGuid():N}"[..(prefix.Length + 9)])
    .WithEnvironment("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
    .Build();
await _container.StartAsync();

// Container HTTPS ve self-signed sertifikayla, elastic kullanıcısıyla geliyor: yalnız atılacak bir test node'u için kabul edilebilir.
Uri uri = new(_container.GetConnectionString());
string[] credentials = Uri.UnescapeDataString(uri.UserInfo).Split(':', 2);
ElasticsearchClientSettings settings = new ElasticsearchClientSettings(
        new SingleNodePool(new UriBuilder(uri) { UserName = "", Password = "" }.Uri), SearchClientFactory.CreateSourceSerializer)
    .Authentication(new BasicAuthentication(credentials[0], credentials[1]))
    .ServerCertificateValidationCallback(CertificateValidations.AllowAll)
    .DefaultMappingFor<Book>(m => m.IndexName(SearchClientFactory.BooksIndex).IdProperty(b => b.Isbn));
[Fact]
public async Task Search_without_turkish_characters_finds_the_book_first()
{
    SearchRequest<Book> request = BookSearchRequestBuilder.Build(new BookSearchInput(new BookSearchFilter("kurk mantolu")), SearchTuning.Tuned);

    SearchResponse<Book> response = await _client.SearchAsync<Book>(request, TestContext.Current.CancellationToken);

    Assert.True(response.IsValidResponse, response.DebugInformation);
    Assert.Equal("9781000000030", response.Hits.First().Source!.Isbn);
}

xunit.v3 4.x, Microsoft.Testing.Platform üzerinde çalışıyor ve .NET 10 SDK'sında dotnet test'in bunu kullanması için repo köküne bir global.json gerekiyor ("test": { "runner": "Microsoft.Testing.Platform" }); olmadan dotnet test "Testing with VSTest target is no longer supported" hatasıyla duruyor. Sonuç (çıktıyı kısalttım):

dotnet test --project tests/Bookstore.Search.Tests --output detailed
passed Bookstore.Search.Tests.BookSearchRequestBuilderTests.Every_sort_ends_with_the_isbn_tie_breaker(sort: "price_asc") (63ms)
passed Bookstore.Search.Tests.BookSearchRequestBuilderTests.Every_sort_ends_with_the_isbn_tie_breaker(sort: null) (0ms)
passed Bookstore.Search.Tests.BookSearchRequestBuilderTests.Every_sort_ends_with_the_isbn_tie_breaker(sort: "newest") (0ms)
passed Bookstore.Search.Tests.BookSearchRequestBuilderTests.Each_facet_ignores_its_own_filter (6ms)
passed Bookstore.Search.Tests.BookSearchRequestBuilderTests.Post_filter_is_omitted_when_no_filter_is_selected (0ms)
passed Bookstore.Search.Tests.BooksIndexTests.Strict_mapping_rejects_an_unknown_field (18ms)
passed Bookstore.Search.Tests.BooksIndexTests.Autocomplete_analyzer_folds_before_splitting_prefixes (15ms)
passed Bookstore.Search.Tests.BooksIndexTests.Search_without_turkish_characters_finds_the_book_first (258ms)
passed Bookstore.Search.Tests.BooksIndexTests.Selected_category_keeps_the_other_options_in_its_facet (62ms)
Test run summary: Passed!
  total: 9
  failed: 0
  succeeded: 9
  skipped: 0
  duration: 31s 251ms

Testlerin kendisi milisaniyeler sürüyor; 31 saniyenin neredeyse tamamı image'ın hazırlanması ve container'ın açılması. Bu yüzden container test class'ı başına bir kez açılıyor (IClassFixture) ve testler veriyi değiştirmiyor. Gerçek bağımlılıklarla çalışan integration ve uçtan uca (E2E) testler için Integration ve E2E Test serisinin "Testcontainers ile Gerçek SQL Server: Container Yaşam Döngüsü, Migration ve Testler Arası Temizlik" yazısı aynı yaklaşımı SQL Server için ayrıntılı anlatıyor. Bu testler 7. yazıdaki rank-eval ile birlikte, analyzer ya da mapping değişikliği yapan her pull request'te çalışmalı.

Üretimde Dikkat

Client sürümünü sunucuyla birlikte yönet. Elastic'in dokümanına göre 9.x client 9.x ve 10.x sunucularla çalışıyor, ama eski bir major sunucuyla çalışmıyor; uyumluluk özellik eşitliği de demek değil, yeni bir minor'ın özelliklerini eski client bilmez. Client semantic versioning'i katı biçimde izlemiyor; minor ve patch sürümlerinde de breaking change olabiliyor (9.3.5'te ES|QL metotlarının dönüş tipi ElasticsearchStreamResponse'a değişti). Güncellemeden önce release notes'u oku ve bu yazıdaki testleri çalıştır.

NEST'ten (Elasticsearch'ün 7.x'e kadarki .NET client'ı) geçiş ayrı bir proje gibi planlanmalı. NEST'in son sürümü 7.17.5; yeni client ayrı bir paket olduğu için ikisi geçiş süresince aynı uygulamada yan yana kurulabiliyor. JSON.NET yerine System.Text.Json, spesifikasyondan üretilen tipler ve aggregation API'sinin değişmesi, kodun büyük kısmının yeniden yazılması demek. Geçişi, kritik sorguların JSON çıktısını eski ve yeni client'la karşılaştıran testlerle yap.

Loglarken ne logladığına bak. Hata log'unda HTTP durumu, hata tipi ve sebep yeterli; isteğin gövdesi kullanıcının arama metnini, cevap ise kişisel veri içerebilir. Debug modu yalnız development'ta.

API key'in rotasyonunu planla. Client singleton ve API key ayarlarda sabit; key'i değiştirmek uygulamanın yeniden başlatılması demek. Kesintisiz rotasyon için iki key'i bir süre birlikte geçerli tut (eskisini yeni deploy bittikten sonra iptal et).

Sık Düşülen Tuzaklar

Descriptor ile aramada index'i vermemek. 9.5.3'te SearchAsync<T>(s => ...) DefaultMappingFor'u kullanmıyor ve /_search'e gidiyor; sonuç, API key'in gördüğü bütün index'lerden gelir.

Her istekte yeni client oluşturmak. Client bağlantı havuzunu, node durumunu ve serializer cache'lerini tutuyor; istek başına oluşturmak bağlantı tüketir ve her seferinde baştan ısınır.

Debug modunu production'da açık bırakmak. Her gövde belleğe alınır, loglara kişisel veri yazılır ve isteklere pretty eklenir.

Source serializer'a converter'ı Add ile eklemek. Varsayılan converter önce geldiği için seninki hiç çalışmaz; çıktıyı serialize ederek doğrula.

429'u kullanıcı isteğinde körü körüne tekrar denemek. Zorlanan cluster'a daha fazla yük bindirir; tekrar denemeyi toplu işlere ve artan bekleme sürelerine bırak.

Analyzer ve mapping'i mock'larla test etmek. Test edilen şey Elasticsearch'ün davranışı; gerçek bir container olmadan test yalnız kendi varsayımını doğrular.

Küçük Bir Deneme

★☆☆ BooksIndexTests'e "ince" aramasının yalnız iki İnce Memed kitabını döndürdüğünü doğrulayan bir test ekle (4. yazıdaki keyword_marker kuralının regresyon testi). tr_keywords'ten "ince"yi çıkarıp testi kır.

★★☆ ThrowExceptions() ayarını açan ikinci bir client oluştur ve errors komutundaki üç senaryoyu onunla çalıştır. Hangi exception tipleri geliyor? Endpoint'te hangisini tercih edersin, neden?

★★★ search/relevance/judgments.json'u Testcontainers testine taşı: container'daki 36 kitapla nDCG@5 hesaplayan bir test yaz ve eşiği 0,9 yap. 7. yazıdaki boost'lardan birini değiştirip testin kırıldığını gör.

Bir sonraki yazıda index'in yaşam döngüsüne geçiyoruz: uygulamanın gerçek index adı yerine bir alias'a bağlanması, yeni mapping'le books-v2'yi kurup alias'ı atomik olarak çevirerek kesintisiz reindex, index template'leri, shard sayısı kararı ve ILM'e (Index Lifecycle Management; index'lerin yaşam döngüsünü otomatik yöneten özellik) kısa bir bakış.