Elasticsearch Serisi 04 – ASP.NET Core’da Completion Suggester ile Autocomplete API Tasarlamak

Özellikle AmazonNetflix, eBay gibi commercial siteler başta olmak üzere bir çok popüler websitelerine baktığımızda, autocomplete(search suggestion) kutularına büyük bir önem verildiğini açıkça görebiliriz sanırım.

Biliyoruz ki iyi bir arama sonucu, son kullanıcı için oldukça büyük bir önem taşımaktadır. Commercial siteler açısından ise son kullanıcıyı hızlı bir şekilde doğru bir ürüne veya kategoriye yönlendirebilmek, satış oranlarını pozitif bir şekilde etkileyecektir.

Henüz biz daha bir şeyler yazarken ilgili sonuçların bize gösterilmesi, çok harika değil mi?

Bende son dönemlerde farklı kişilerden almış olduğum e-postalar doğrultusunda, autosuggest özelliğini Elasticsearch – Completion Suggester ve .NET Core kullanarak nasıl gerçekleştirebiliriz konusu hakkında bir şeyler yazmaya karar verdim.

Elasticsearch içerisinde autocomplete/suggest özelliğini implemente edebilmenin “ngrams”, “prefix queries” ve “completion suggester” gibi bir kaç farklı yolu vardır. Farklı implementasyon yollarının ise, resulting ve indexing hızları, document size’ları gibi farklı tradeoff’ları da bulunmaktadır. Ben bu makale kapsamında “autocomplete/search-as-you-type” fonksiyonalitesi için, en performanslı çözüm olan(bence) “completion suggester” özelliğini kullanarak autocomplete’i nasıl implemente edebiliriz konusuna değinmeye çalışacağım.

Completion Suggester

Sanırım söz konusu son kullanıcıya instant feedback verebilmek olduğunda, iyi bir suggestion sonucu ile birlikte resulting hızı da büyük bir önem taşımaktadır. Bu noktada, completion suggester autocomplete konusunda diğer alternatif yollardan farklı olarak çalışmaktadır. Suggest edilmek istenen tüm kombinasyonlar, “completion” type’ına sahip bir mapping ile elasticsearch üzerine indexlenmesi gerekmektedir. Completion suggester’ın fast lookup işlemini gerçekleştirebilmesi için ise, FST(Finite-state transducer) adı verilen in-memory data structure’ı kullanmaktadır. Bu sayede prefix lookup işlemini diğer term-based query’lere göre daha hızlı bir şekilde gerçekleştirebilmektedir.

Çalışma mantığını daha iyi anlayabilmemiz için elastic engineering blog’u üzerinde bulunan örneğe bir bakalım. FST üzerinde “hotel“, “marriot“, “mercure“, “munchen” ve “munich” kelimelerinin olduğunu varsayalım.

Suggester, yukarıdaki in-memory graph üzerinde kullanıcı tarafından girilen text input’a göre soldan sağa doğru bir matching işlemi gerçekleştirmektedir. Örneğin kullanıcı input olarak “h” text’ini girdiğinde, match olacak tek olasılık “hotel” olduğu için bu kelimeyi anında tamamlamaktadır. Eğer kullanıcı “m” text’ini girerse, bu sefer suggester “m” ile başlayan tüm kelimeleri listeleyecektir.

Completion suggester’ın dezavantajı ise, yukarıda olduğu gibi matching işlemi her zaman soldan sağa doğru başlamaktadır. Örneğin “Sam” text input’u “Samsung Note 8” ile match olacaktır “Note 8 Samsung” ile değil. Bu tarz durumlarda ise term-based query’ler daha fazla ön plana çıkmaktadır. Ancak biraz öncede bahsettiğim gibi, suggest edilmek istenen tüm kombinasyonları completion suggester ile tek bir suggest output’u için index’lersek, “Note 8 Samsung” kelimesinin match işlemini de gerçekleştirebiliriz. Buna örneğimizde değineceğim.

Ayrıca completion suggester ile birlikte “Fuzzy Matching” ve scoring işlemleri için “Weights” belirtebilmek de mümkündür.

Senaryo

Bir e-ticaret firmasında çalıştığımızı düşünelim. Search domain’inden sorumlu product owner ise bizden, “brand” ve “product name” field’larına göre real-time‘a yakın bir sonuç veren autocomplete yapmamızı istiyor.

Bunun için ilk olarak suggest edeceğimiz item’ları, elasticsearch üzerine feed yapmamız gerekmektedir.

1) Mapping ve Index’in Oluşturulması

Completion suggester’ı kullanabilmemiz için, öncelikle completion type’ına sahip bir mapping oluşturmamız gerekmektedir. Bunun için öncelikle “Autocomplete.Business.Objects” isminde bir .NET Core class library oluşturalım ve NuGet package manager üzerinden “NEST” library’sini dahil edelim.

Ayrı bir class library yapmamızın sebebi ise, burada tanımlayacak olduğumuz modelleri hem feeder uygulamasında hem de autocomplete API‘ında kullanacak olmamızdır.

İlk olarak “Product” ve “ProductSuggestResponse” model’lerini aşağıdaki gibi tanımlayalım.

using Nest;

namespace Autocomplete.Business.Objects
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public CompletionField Suggest {get;set;}
    }
}
using System.Collections.Generic;

namespace Autocomplete.Business.Objects
{
    public class ProductSuggestResponse
    {
        public IEnumerable<ProductSuggest> Suggests { get; set; }
    }

    public class ProductSuggest
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public double Score { get; set; }  
    }
}

Product” model’i içerisindeki “Suggest” property’sini, autocomplete işlemi sırasında suggest etmek istediğimiz text’ler için kullanacağız.

Autocomplete.Business” isminde yeni bir .NET Core class library daha oluşturalım ve “Autocomplete.Business.Objects” ile “NEST” library’sini burayada dahil edelim. Ardından “IAutocompleteService” isminde bir interface tanımlayalım.

using System.Collections.Generic;
using System.Threading.Tasks;
using Autocomplete.Business.Objects;

namespace Autocomplete.Business
{
    public interface IAutocompleteService
    {
        Task<bool> CreateIndexAsync(string indexName);
        Task IndexAsync(string indexName, List<Product> products);
        Task<ProductSuggestResponse> SuggestAsync(string indexName, string keyword);
    }
}

ve aşağıdaki gibi implemente edelim.

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Autocomplete.Business.Objects;
using Nest;

namespace Autocomplete.Business
{
    public class AutocompleteService : IAutocompleteService
    {
        readonly ElasticClient _elasticClient;

        public AutocompleteService(ConnectionSettings connectionSettings)
        {
            _elasticClient = new ElasticClient(connectionSettings);
        }

        public async Task<bool> CreateIndexAsync(string indexName)
        {
            var createIndexDescriptor = new CreateIndexDescriptor(indexName)
                .Mappings(ms => ms
                          .Map<Product>(m => m
                                .AutoMap()
                                .Properties(ps => ps
                                    .Completion(c => c
                                        .Name(p => p.Suggest))))

                         );

            if (_elasticClient.IndexExists(indexName.ToLowerInvariant()).Exists)
            {
                _elasticClient.DeleteIndex(indexName.ToLowerInvariant());
            }

            ICreateIndexResponse createIndexResponse = await _elasticClient.CreateIndexAsync(createIndexDescriptor);

            return createIndexResponse.IsValid;
        }

        public async Task IndexAsync(string indexName, List<Product> products)
        {
            await _elasticClient.IndexManyAsync(products, indexName);
        }

        public async Task<ProductSuggestResponse> SuggestAsync(string indexName, string keyword)
        {
            ISearchResponse<Product> searchResponse = await _elasticClient.SearchAsync<Product>(s => s
                                     .Index(indexName)
                                     .Suggest(su => su
                                          .Completion("suggestions", c => c
                                               .Field(f => f.Suggest)
                                               .Prefix(keyword)
                                               .Fuzzy(f => f
                                                   .Fuzziness(Fuzziness.Auto)
                                               )
                                               .Size(5))
                                             ));

            var suggests = from suggest in searchResponse.Suggest["suggestions"]
                              from option in suggest.Options
                              select new ProductSuggest
                              {
                                    Id = option.Source.Id,
                                    Name = option.Source.Name,
                                    SuggestedName = option.Text,
                                    Score = option.Score
                              };

            return new ProductSuggestResponse
            {
                Suggests = suggests
            };
        }
    }
}

Yukarıdaki “CreateIndexAsync” method’una bakarsak, burada “Product” model’inin mapping işlemini gerçekleştiriyoruz. Completion alanı olarak “Product” model’inin içerisindeki “Suggest” property’sini belirtiyoruz. Bu noktada analyzer olarak ise default “simple” analyzer kullanılmaktadır. Simple analyzer lower case olarak tüm text’i, terms’lere bölmektedir. Farklı ihtiyaçlar karşısında ise analyzer’ı, completion üzerindeki “Analyzer” method’u ile değiştirebilmek mümkündür.

SuggestAsync” method’unu ise autocomplete işlemi sırasında kullanacağız. Burada basit olarak “Suggest” field’ı üzerinden completion işlemi gerçekleştireceğimizi belirtiyoruz. Kullanıcının gireceği keyword’u ise, “Prefix” method’u ile completion’a set ediyoruz. “Fuzzy” ise autocomplete işlemi sırasında, olmazsa olmazlardandır sanırım. Sonuçta hepimiz bir şeyler yazarken basit typo hataları yapabiliyoruz, değil mi? 🙂

Son olarak “searchResponse” üzerinden gelen suggestion option’larını ise, suggest edilen “Text” ve “Score” bilgileri ile “ProductSuggest” model’ine map’liyoruz.

NOT: Suggest edilen text’in orjinal document’ına, “option” ın “Source” property’si üzerinden erişebilmek mümkündür.

Artık suggestion için kullanacak olduğumuz document’ları, feed edecek olan console application’ı oluşturmaya başlayabiliriz. Bunun için öncelikle “Autocomplete.Feed” isminde bir .NET Core console application projesi oluşturalım ve “Autocomplete.Business.Objects” ile “Autocomplete.Business” library’lerini referans olarak ekleyelim.

Projenin oluşturulmasının ardından, test işlemlerimiz için aşağıdaki komut ile Docker üzerinde bir elasticsearch instance’ı ayağa kaldıralım.

docker run -d -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:6.1.2

Şimdi “Program.cs” class’ının içeriğini aşağıdaki gibi güncelleyelim.

using System;
using System.Collections.Generic;
using Autocomplete.Business;
using Autocomplete.Business.Objects;
using Nest;

namespace Autocomplete.Feed
{
    class Program
    {
        static void Main(string[] args)
        {
            List<Product> products = new List<Product>();

            products.Add(new Product()
            {
                Id = 1,
                Name = "Samsung Galaxy Note 8",
                Suggest = new CompletionField()
                {
                    Input = new [] { "Samsung Galaxy Note 8", "Galaxy Note 8", "Note 8" }
                }
            });

            products.Add(new Product()
            {
                Id = 2,
                Name = "Samsung Galaxy S8",
                Suggest = new CompletionField()
                {
                    Input = new[] { "Samsung Galaxy S8", "Galaxy S8", "S8" }             
                }
            });

            products.Add(new Product()
            {
                Id = 3,
                Name = "Apple Iphone 8",
                Suggest = new CompletionField()
                {
                    Input = new[] { "Apple Iphone 8", "Iphone 8" }
                }
            });

            products.Add(new Product()
            {
                Id = 4,
                Name = "Apple Iphone X",
                Suggest = new CompletionField()
                {
                    Input = new[] { "Apple Iphone X", "Iphone X" }
                }
            });

            products.Add(new Product()
            {
                Id = 5,
                Name = "Apple iPad Pro",
                Suggest = new CompletionField()
                {
                    Input = new[] { "Apple iPad Pro", "iPad Pro" }
                }
            });

            var connectionSettings = new ConnectionSettings(new Uri("http://localhost:9200"));
            IAutocompleteService autocompleteService = new AutocompleteService(connectionSettings);
            string productSuggestIndex = "product_suggest";

            bool isCreated = autocompleteService.CreateIndexAsync(productSuggestIndex).Result;

            if(isCreated)
            {
                autocompleteService.IndexAsync(productSuggestIndex, products).Wait();
            }
        }
    }
}

Yukarıdaki kod bloğuna bakarsak, öncelikle autocomplete’de suggestion işlemi için kullanacak olduğumuz “Product” ları oluşturduk. “Product” oluşturma sırasında ise “CompletionField” property’sine, her bir product için match olmasını istediğimiz input’ları set ettik. Yani kullanıcı “Galaxy Note 8” de yazsa, yada sadece “Note 8” de yazsa bu text’in “Samsung Galaxy Note 8” e match olmasını sağlayabileceğiz.

Daha önce oluşturmuş olduğumuz “AutocompleteService” class’ı ile de, index’in oluşturulabilmesini ve product’ların feed edilebilmesini sağladık.

Şimdi feed projesi hazır olduğuna göre, çalıştıralım ve “product_suggest” index’inin oluşturulmasını sağlayalım. Eğer başarılı bir şekilde çalıştırıldı ise, elasticsearch üzerinde aşağıdaki gibi bir mapping’e sahip “product_suggest” index’i oluşturulmuş olacaktır.

GET product_suggest/_mapping

{
   "product_suggest": {
      "mappings": {
         "product": {
            "properties": {
               "id": {
                  "type": "integer"
               },
               "name": {
                  "type": "text",
                  "fields": {
                     "keyword": {
                        "type": "keyword",
                        "ignore_above": 256
                     }
                  }
               },
               "suggest": {
                  "type": "completion",
                  "analyzer": "simple",
                  "preserve_separators": true,
                  "preserve_position_increments": true,
                  "max_input_length": 50
               }
            }
         }
      }
   }
}

1) Autocomplete API’ın Tasarlanması

Artık tek yapmamız gereken, autocomplete’i dış dünyaya sunabilmek için bir API tasarlamak. Bunun için “Autocomplete.API” isminde bir .NET Core Web API projesi oluşturalım ve “Autocomplete.Business.Objects“, “Autocomplete.Business” ve “NEST” library’lerini referans olarak dahil edelim.

Ardından “ProductSuggests” isminde bir controller ekleyelim ve aşağıdaki gibi kodlayalım.

using System.Threading.Tasks;
using Autocomplete.Business;
using Autocomplete.Business.Objects;
using Microsoft.AspNetCore.Mvc;

namespace Autocomplete.API.Controllers
{
    [Route("api/product-suggests")]
    public class ProductSuggestsController : Controller
    {
        readonly IAutocompleteService _autocompleteService;
        const string PRODUCT_SUGGEST_INDEX = "product_suggest";

        public ProductSuggestsController(IAutocompleteService autocompleteService)
        {
            _autocompleteService = autocompleteService;
        }

        [HttpGet]
        public async Task<ProductSuggestResponse> Get(string keyword)
        {
            return await _autocompleteService.SuggestAsync(PRODUCT_SUGGEST_INDEX, keyword);
        }
    }
}

Get” method’unda basit olarak, “IAutocompleteService” class’ını kullanarak “ProductSuggestResponse” unu dönüyoruz

Son olarak “Startup” class’ı içerisinde servislerin injection işlemini gerçekleştirmemiz gerekmektedir.

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();

    services.AddSingleton(x => new ConnectionSettings(new Uri("http://localhost:9200")));
    services.AddTransient<IAutocompleteService, AutocompleteService>();
}

Hepsi bu kadar.

Autocomplete özelliğini test edebilmemiz için öncelikle API projesini çalıştıralım ve ardından kullanıcının “iph” text’ini girdiğini varsayalım.

GEThttp://localhost:5000/api/product-suggests?keyword=iph

Gelen response’a bakarsak eğer “iph” text’i için “Iphone 8“, “Iphone X” ve “iPad Pro” gibi ilgili sonuçların geldiğini görebiliriz.

Şimdi ise kullanıcının “iph” yerine “app” text’ini girdiğini düşünürsek:

Bu sefer de “Apple Iphone 8“, “Apple Iphone X” ve “Apple iPad Pro” sonuçları kullanıcıya suggest edilmiştir.

Sonuç

Makalenin girişinde de bahsettiğim gibi, bu autocomplete özelliğini implemente edebilmenin elasticsearch içerisinde tradeoff’ları ile beraber bir kaç farklı yolu bulunmaktadır. Completion suggester yapısı gereği diğer term-based query’lere göre daha performanslı olarak çalışmaktadır. Matching işlemine text’in başlangıcından başlaması ise bir dezavantajıdır. Bunun yanında ayrıca sort order opsiyonları ise kısıtlıdır.

https://github.com/GokGokalp/Elasticsearch-Autocomplete-API-Sample

Referanslar

https://www.elastic.co/blog/you-complete-me

https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters-completion.html

Gökhan Gökalp

View Comments

  • Thanks for the great article! You are describing a lot. I only wanted to ask what if we are using some sort of db with a big amount of info in it. How would it be right to create suggesters - I see you inputing them by yourself

    • Hi Kirill, thanks for your interest. Could you please give me a little more information about your question? What you mean with "I see you inputting them by yourself"? Is it about indexing data to the elassticssearch from the db or something else?

      • Well, you have your own examples in your code. I've changed your solution a bit and now I have an opportunity to post some kind of news with fields like Name, Tags, Short Description. I've wanted to ask if you know how to connect ES with MS SQL for example(I've read smth about logstash). And is it possible to suggest not only Name but Description too? Thanks for your time

        • Hi Kirill, I'm sorry for late reply, these days I a little bit busy with marriage preparing. :) So, yes you can set multiple inputs to one suggestion item. E.g : Input = new[] { "Name", "Tags", "Description" } If these fields match any search term, you can change will be displayed text what you want. Is the same thing with my example. Thanks.

  • Hi.
    Thanks for your nice article.
    I have a one problem for implementing autocomplete suggester in asp.net core and sql server.
    How to create index of multiple fields of some tables?
    For example, I search by Product name or category name or product attributes and etc.
    Thanks.

    • Hi Hamid, thanks.

      To do that, there are a few ways you can choose.
      For example, while you prepare index data, you can add more input text for search by product name, category name or attributes etc...

      products.Add(new Product()
      {
      Id = 3,
      Name = "Apple Iphone 8",
      Suggest = new CompletionField()
      {
      Input = new[] { "Apple Iphone 8", "Iphone 8", "Cell Phones", "128GB Iphone 8", "Silver 128GB Iphone 8", "ADD WHAT YOU WANT" }
      }
      });

      or you can create different index and use term analyser instead of completion suggester. Thus you can search by product name, category name or product attributes as parallel, then you can aggregate the result simultaneously.

      Regards

  • Merhaba,
    ES direk bizim var olan bir dbdeki tabloyu içine alıp her insert update delete yaptığımızda da ES'yi de mi güncellememiz gerekiyor kurulu bir sistemde ürünler tablosunda ki milyonlarca satır arasından arama yapması için ES nasıl kullanilabilir.

    • Merhaba evet, nerede arama yapmasını istiyorsanız onunla ilgili index'lerinizi oluşturmanız ve her değişimde o index'leri up-to-date tutmanız gerekmektedir.

  • Hocam merhaba,
    Suggest ile aramada sorun yok ama ben buna ek bir field daha ilave etmek istediğimde hata alıyorum sürekli.
    Yani suggest ile arasın ama sadece userid =5 olanları getirsin şeklinde bir sorgu yazamadım bir türlü.

    Yardımcı olabilir misin?

    • Merhaba, completion suggestor FST yapısını kullandığı için bu iş için uygun mudur bilemedim. Ek filtreler takmak istiyorsanız eğer, completion suggestor yerine term-based bir yapı kullanmanızı önerebilirim.

  • Merhabalar, yapmış olduğunuz örnekte tüm işlemleri ElasticSearch üzerinde gerçekleştiriyorsunuz. Verilerimiz Ms Sql üzerindeyse bu durum da ne olur?

    • Tam olarak açabilir misiniz? Yapmış ve kullanmış olduğumuz özellik burada elasticsearch'e ait bir özellik. Eğer aynı işlemi elasticsearch yerine Ms Sql de yapmaktan bahsediyorsanız, farklı bir yöntem izlemelisiniz.

  • Merhaba, emekleriniz için teşekkür ederim.
    Ancak 2021-Eylül ayında uygulamayı .net core 2.0 ile create edip
    NEST" Version="5.6.0" ile index'i oluşturamıyor.
    Bunun için driver paketini yükseltmek gerekiyor. Denemek isteyen arkadaşların driver'i son versiona yükseltip code'da index ile ilgili olan kısımları _elasticClient.Indices.Exist() yada _elasticClient.Indices.CreateAsync() gibi revize etmeleri gerekiyor :)

  • Merhaba Gökhan Bey,
    Açıkçası diğer yorum yapan arkadaşlar da sormaya çalışmışlar ancak tam anlaşılamamış sanırım.
    Mevcut MSSQL veritabanımızdaki kayıtların, ES'e aktarılması, Up to date tutulması, ve search işlemlerinin ES üzerinden gerçekleştirilmesine ilişkin, nasıl çalışmalar yapmamız gerekli?
    Yani;
    1- Mevcut MSSQL kayıtlarının ES'e aktarılması,
    2-ES'in MSSQL ile senkronize tutulması için, Projemizdeki her MSQQL'e giden Insert/Update/Delete metodlarının altından ES ile ilgili metodları da mı çağırmamız gerekli?
    Sanırım mevcudu aktarmak için tek seferlik çalışacak bir döngü oluşturarak ilgili tüm tabloları insert etmemiz gerekecek. Daha sonraki işlemlerde ise MSSQL ile birlikte ES'i de güncelleye kodlar yazmamız gerekecek gibi..
    Bunlara kısa kod örnekleri verebilirseniz sevinirim.

    • Merhaba teşekkürler yorumunuz için.
      Evet tek seferlik bir migration için hazırlayacak olduğunuz script/uygulama ile kayıtları istediğiniz doğrultuda ES ye aktarabilirsiniz. Söz konusu kayıtların up-to-date tutulması olduğunda ise, en güzel çözüm olarak event-based sistemlerden yararlanabilirsiniz. İlgili kayıtlarınızda ilgili domain'ler içerisinde herhangi bir değişim olduğunda bir event publish edebilir ve ilgili kayıt'ı ES tarafında async olarak update edebilirsiniz. Bu tarz işlemleri invalidation işlemleri olarak da aratabilirsiniz.

  • Merhaba, suggester ile birlikte “Fuzzy Matching” örneğini uyguladım fakat scoring işlemleri için “Weights” belirtmeyi nasıl yapacağımı bulamadım. Bir örnek yapabilir misiniz?
    Teşekkürler.

    • Merhaba, kusura bakmayın geç cevap için. Açıkcası bu makaleyi yazalı epey süre geçmiş. :) Söz vermemekle birlikte boş bir vakit bulabilirsem bakmaya çalışacağım. Teşekkür ederim.

Recent Posts

Containerized Uygulamaların Supply Chain’ini Güvence Altına Alarak Güvenlik Risklerini Azaltma (Güvenlik Taraması, SBOM’lar, Artifact’lerin İmzalanması ve Doğrulanması) – Bölüm 1

{:tr}Bildiğimiz gibi modern yazılım geliştirme ortamında containerization'ın benimsenmesi, uygulamaların oluşturulma ve dağıtılma şekillerini oldukça değiştirdi.…

8 ay ago

Identity & Access Management İşlemlerini Azure AD B2C ile .NET Ortamında Gerçekleştirmek

{:tr}Bildiğimiz gibi bir ürün geliştirirken olabildiğince farklı cloud çözümlerinden faydalanmak, harcanacak zaman ve karmaşıklığın yanı…

1 yıl ago

Azure Service Bus Kullanarak Microservice’lerde Event’ler Nasıl Sıralanır (FIFO Consumers)

{:tr}Bazen bazı senaryolar vardır karmaşıklığını veya eksi yanlarını bildiğimiz halde implemente etmekten kaçamadığımız veya implemente…

2 yıl ago

.NET Microservice’lerinde Outbox Pattern’ı ile Eventual Consistency için Atomicity Sağlama

{:tr}Bildiğimiz gibi microservice architecture'ına adapte olmanın bir çok artı noktası olduğu gibi, maalesef getirdiği bazı…

2 yıl ago

Dapr ve .NET Kullanarak Minimum Efor ile Microservice’ler Geliştirmek – 02 (Azure Container Apps)

{:tr}Bir önceki makale serisinde Dapr projesinden ve faydalarından bahsedip, local ortamda self-hosted mode olarak .NET…

2 yıl ago