.NET 5 ve gRPC ile Servisler Arasında Yüksek Performanslı, Stream Tabanlı İletişim

Günümüz teknoloji çağının ihtiyaçları nedeniyle geliştirdiğimiz bir çok uygulamalarımızı, microservice mimarisi çatısı altında distributed olarak geliştirmeye çalışıyoruz. Ayrıca distributed servisler arasındaki iletişimi ise bir çok noktada REST  (HTTP JSON) yaklaşımıyla gerçekleştirmeye çalışıyoruz.

Bu makale kapsamında ise gRPC kullanarak servisler arasında yüksek performanslı, stream tabanlı iletişimi nasıl gerçekleştirebiliriz ve gRPC kullanarak ne gibi faydalar elde edebiliriz konusuna değinmeye çalışacağım.

Neden?

Bildiğimiz gibi bazen bazı senaryolar vardır milisaniyelerin de öneminin olduğu. Bu gibi durumlarda performansı arttırabilmek için genelde kodsal açıdan refactoring’ler yapmaya veya implementasyon yöntemlerini değiştirmeye çalışırız (eminim bir developer olarak sizde karşılaşmışsınızdır). İşte bu noktada gRPC de senaryolara göre performansı arttırabilmek için yararlanabileceğimiz harika bir implementasyon/iletişim yaklaşımıdır.

Özellikle servisler arası iletişim sırasında aktarılacak olan payload’un boyutu büyükse veya streaming gibi yaklaşımlardan yararlanmak istiyorsak, gRPC bu noktada harika bir iletişim seçimi oluyor. Çünkü gRPC, HTTP/2 protokolü üzerinden protocol buffer(Protobuf) kullanarak binary serialization yaptığı ve binary data transfer ettiği için servisler arası iletişimde oldukça yüksek bir performans sağlayabilmektedir.

Ayrıca .NET 5 ile birlikte HTTP/2 ve Kestrel özelinde gerçekleştirilen allocation’ların azaltılması ve concurrency’nin iyileştirilmesi gibi optimizasyonlar ile, gRPC daha da iyi bir noktaya gelmiştir.

REST yaklaşımında ise bildiğimiz gibi servisler arasında data’yı JSON veya XML olarak transfer ettiğimiz için, payload boyutunun binary data’ya göre daha büyük olması, serialization süresi gibi çeşitli sebeplerden dolayı servisler arası iletişim senaryolara göre daha yavaş olabilmektedir.

Örneğin

Örneğin bir e-ticaret firmasında çalıştığımızı ve bir tedarikçi portal’i geliştirdiğimizi varsayalım. Bizden ise tedarikçilerin ürünlerini bizim sistemimize toplu ve hızlı bir şekilde CSV/XML gibi bir formatta aktarabilmeleri istenmektedir.

Normal şartlarda tedarikçilerin ürünlerini toplu bir şekilde upload edebilecekleri bir endpoint geliştiririz. Bu endpoint içerisinde ise CSV/XML dosyasını parse ettikten sonra ürünlerin sistem içerisine dahil olabilme süreçlerini başlatabilmek için ilgili internal API‘lara/Queue’lara istekler göndeririz. Asıl challenge ise işte bu noktada başlıyor.

İlgili internal API’lara istekler gönderirken optimal olabilmesi açısından duruma göre ya chunk’lar halinde yada tek tek göndermeye çalışırız. Bu işlemleri bir HTTP JSON API‘ı üzerinden sorunsuz bir şekilde elbette gerçekleştirebiliriz. Fakat bu noktada bazı dezavantajlar ile de karşılaşabiliriz. Örneğin aktarılacak olan payload’ların boyutu ürün bilgilerinden dolayı büyük olacağı için, bu noktada serialization veya network kaynaklı performans sorunlarıyla karşılaşabiliriz. Ayrıca aktarılacak çok fazla data da mevcutsa, buda daha fazla bir zaman kaybı olarak bize geriye dönmektedir.

İşte bu noktada gRPC‘nin binary serialization, HTTP/2 ve streaming gibi özelliklerinden yararlanarak, bu internal iletişim sürecini daha efektif, performanslı ve asynchronous bir hale getirebilmek mümkün olabilmektedir.

gRPC ile Client Streaming

Client streaming, gRPC‘nin 4 farklı yaklaşım tipinden birisidir. Diğerleri ise “Unary”, “Server streaming” ve “Bidirectional streaming” dir.

Client streaming özellikle server’a sürekli bir dizi data gönderilmesi gereken durumlarda oldukça faydalı ve performanslı bir yaklaşımdır. Ayrıca streaming yaklaşımı özellikle düşük bir gecikme süresi ile yüksek bir aktarım hızı söz konusu olduğunda güzel bir seçim olmaktadır.

Örnek senaryomuzda ise tedarikçi portal’i için birden çok ürünü bir noktadan bir başka noktaya stream ederek göndereceğimiz için, client streaming yaklaşımını implemente edeceğiz.

Konuyu daha iyi anlayabilmek için şimdi bir örnek gerçekleştirelim.

Implementasyona başlamadan önce aşağıdaki gibi bir CSV dosyasına sahip olduğumuzu varsayalım.

Server Implementasyonu ile başlayalım

Geliştirmeye ise ilk olarak server kısmından başlayalım. Yani ürünleri yönetecek olduğumuz asıl ürün servisi. Bunun için “MyTodoStore.Product.GRPC” adında bir .NET 5 gRPC servisi oluşturalım.

dotnet new grpc -n MyTodoStore.Product.GRPC

gRPC contract-first bir framework olduğu için ilk olarak Protobuf kullanarak contract’ları tanımlamamız gerekmektedir.

Protobuf’ı ise herhangi bir programlama diline bağımlı olmadan servisler arası iletişimde kullanabileceğimiz bir contract/interface olarak düşünebiliriz. Ayrıca oluşturulacak olan contract’lar ilgili framework’ler tarafından kullanılarak, servisler arası iletişim için gerekli olan altyapının otomatik olarak oluşturulabilmesini de sağlamaktadır.

Şimdi “Protos” klasörü altında “product.proto” dosyasını aşağıdaki gibi oluşturalım.

syntax = "proto3";

option csharp_namespace = "MyTodoStore.Product.GRPC";

package mytodostore_product_grpc;

service ProductGRPCService {
  rpc ImportProductsStream(stream ImportProductRequest) returns (ImportProductResponse);
}

message ImportProductRequest {
  int32 supplier_id = 1;
  string sku = 2;
  string name = 3;
  string description = 4;
  string brand = 5;
}

message ImportProductResponse {
  int32 count = 1;
}

Bu proto dosyası içerisinde bizim için önemli olan nokta, RPC method tanımlamalarını yaptığımız “service” kısmıdır. Bu kısımda ürünlerin sistem içerisine bir stream olarak aktarılabilmesinden sorumlu olacak olan “ImportProductsStream” adlı bir RPC method’u tanımladık. Bu method stream olarak “ImportProductRequest” message’ı kabul edecek ve işlem tamamlandığında ise geriye toplam aktarılan ürün bilgisini içeren bir “ImportProductResponse” message’ı dönecek.

Message structure’ları içerisindeki field’lara atanan unique rakamlar ise, serialize edilen binary data içerisinde field’ların tanımlanabilmesi için kullanılmaktadır. Böylelikle message içerisine yeni bir field eklendiğinde, o güncellemeye sahip olmayan uygulamanın parser’ı deserialization işlemi sırasında herhangi bir sorun çıkartmadan ilgili field’ı es geçerek işlemini gerçekleştirebilmekte ve uyumsuzluk problemi çıkartmamaktadır.

NOT: Proto dosyası oluşturma hakkında daha detaylı bilgiye ise buradaki kaynaktan ulaşabilirsiniz.

product.proto” dosyasını oluşturduktan sonra code generation işleminin gerçekleşebilmesi için, proje dosyası içerisinde de aşağıdaki gibi onu tanımlamamız gerekmektedir.

Böylece “MyTodoStore.Product.GRPC” projesini build ettiğimizde, compiler oluşturduğumuz proto dosyasını kullanarak gerekli code generation işlemini server için gerçekleştirecektir. Kısacası tanımlama işlemini gerçekleştirdikten sonra projeyi build etmeyi unutmayalım.

Şimdi ise “Services” klasörü altında “ProductService” isminde aşağıdaki gibi bir class oluşturalım ve servis implementasyonunu gerçekleştirelim.

using System;
using System.Threading.Tasks;
using Grpc.Core;

namespace MyTodoStore.Product.GRPC
{
    public class ProductService : ProductGRPCService.ProductGRPCServiceBase
    {
        public override async Task<ImportProductResponse> ImportProductsStream(IAsyncStreamReader<ImportProductRequest> requestStream, ServerCallContext context)
        {
            var importResponse = new ImportProductResponse();

            await foreach (var importProductItem in requestStream.ReadAllAsync())
            {
                // product import operations...

                importResponse.Count += 1;
                Console.WriteLine($"1 product has been imported. SKU: {importProductItem.Sku} Brand: {importProductItem.Brand}");
            }

            Console.WriteLine("Import products stream has been ended.");

            return importResponse;
        }
    }
}

Gördüğümüz gibi bu class’ı, compiler tarafından oluşturulan “ProductGRPCServiceBase” abstract class’ından türettik. Ardından “ImportProductsStream” adlı RPC method’unu burada override ederek, içerisinde stream’i consume ettiğimiz basit bir business kodu ile implemente ettik.

Bu business kodundaki amacımız ise ürünleri server’a stream ederken bu işlemi async bir şekilde başlatabildiğimizi, bir başka değişle server’ın tüm ürün stream akışının tamamlanmasını beklemeden async bir şekilde çalışmaya başlayabildiğini gösterebilmek. Bunun için sisteme aktarılan ürün sayısını “foreach” loop’u içerisinde arttıracağız ve geriye toplam kaç adet ürün aktarıldığı bilgisini içeren “importResponse” messagı’ını döneceğiz.

Servis implementasyonunu tamamladıktan sonra “Startup” dosyası içerisinde aşağıdaki gibi endpoint mapping işlemini de gerçekleştirmemiz gerekmektedir.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGrpcService<ProductService>();

        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
        });
    });
}

Neredeyse hazırız.

Eğer sizde benim gibi macOS kullanıyorsanız, aşağıdaki gibi Kestrel’i configure etmeniz gerekmektedir. Çünkü KestrelmacOS içerisinde HTTP/2 ile TLS‘i desteklememektedir.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Hosting;

namespace MyTodoStore.Product.GRPC
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.ConfigureKestrel(options =>
                    {
                        options.ListenLocalhost(5000, o => o.Protocols =
                            HttpProtocols.Http2);
                    });

                    webBuilder.UseStartup<Startup>();
                });
    }
}

Artık “MyTodoStore.Product.GRPC” servisi hazır durumda.

Client Implementasyonu

Şimdi ise tedarikçi ürün işlemleri için kullanacağımız bir RESTful API geliştirelim. Temel olarak bu API’da içerisinde ise CSV dosyasının upload işlemini implemente edeceğiz.

Bunun için “MyTodoStore.SupplierProduct.API” adında bir .NET 5 Web API projesi oluşturalım.

dotnet new webapi -n MyTodoStore.SupplierProduct.API

Ardından CSV dosyasını kolayca parse edebilmemiz ve gRPC client implementasyonunu gerçekleştirebilmemiz için aşağıdaki paket’leri NuGet üzerinden projeye dahil edelim.

dotnet add package CsvHelper
dotnet add package Grpc.Net.Client
dotnet add package Grpc.Net.ClientFactory

Paketleri projeye dahil ettikten sonra ise oluşturmuş olduğumuz “MyTodoStore.Product.GRPC” servisinin “product.proto” dosyasını, bu proje altında “Protos” klasörü oluşturarak içerisine kopyalayalım. Daha sonra yeni kopyaladığımız proto dosyası içerisindeki “csharp_namespace” alanını ise, yeni oluşturmuş olduğumuz proje namespace’i ile güncelleyelim.

option csharp_namespace = "MyTodoStore.SupplierProduct.API";

Ardından bu proje dosyası içerisinde de “product.proto” dosyasını aşağıdaki gibi tanımlamamız gerekmektedir.

Proto dosyasını bu projede tanımlarken buradaki farklı olan nokta ise, “GrpcServices” attribute değerinin “Client” olmasıdır. Böylece compiler, server tarafına bağlanabilmemiz için gerekli olan C# kodunu bu sefer client için oluşturacaktır. Bu işlemin ardından ise daha önce de yaptığımız gibi projeyi build etmeyi unutmayalım.

Şimdi “Models” isminde bir klasör oluşturarak içerisinde CSV dosyasını temsil edecek olan modeli aşağıdaki gibi tanımlayalım.

namespace MyTodoStore.SupplierProduct.API.Models
{
    public class SupplierProductModel
    {
        public int SupplierID { get; set; }
        public string SKU { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public string Brand { get; set; }
    }
}

Ardından servis implementasyonunu gerçekleştireceğimiz “Services” klasörünü oluşturalım. İçerisinde ise “SupplierProductService” adında bir servisi, aşağıdaki gibi implemente edelim.

using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

namespace MyTodoStore.SupplierProduct.API.Services
{
    public interface ISupplierProductService
    {
        Task<int> ImportProductsAsync(IFormFile formFile);
    }
}
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading.Tasks;
using CsvHelper;
using CsvHelper.Configuration;
using Microsoft.AspNetCore.Http;
using MyTodoStore.SupplierProduct.API.Models;

namespace MyTodoStore.SupplierProduct.API.Services
{
    public class SupplierProductService : ISupplierProductService
    {
        private readonly ProductGRPCService.ProductGRPCServiceClient _productGRPCServiceClient;

        public SupplierProductService(ProductGRPCService.ProductGRPCServiceClient productGRPCServiceClient)
        {
            _productGRPCServiceClient = productGRPCServiceClient;
        }

        public async Task<int> ImportProductsAsync(IFormFile formFile)
        {
            var config = new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                Delimiter = ";"
            };

            using var importProductStream = _productGRPCServiceClient.ImportProductsStream();

            using (var reader = new StreamReader(formFile.OpenReadStream()))
            using (var csv = new CsvReader(reader, config))
            {
                IAsyncEnumerable<SupplierProductModel> products = csv.GetRecordsAsync<SupplierProductModel>();

                await foreach (SupplierProductModel product in products)
                {
                    ImportProductRequest importProductRequest = new()
                    {
                        SupplierId = product.SupplierID,
                        Sku = product.SKU,
                        Name = product.Name,
                        Description = product.Description,
                        Brand = product.Brand
                    };

                    await importProductStream.RequestStream.WriteAsync(importProductRequest);
                }
            }
            await importProductStream.RequestStream.CompleteAsync();

            ImportProductResponse response = await importProductStream;

            return response.Count;
        }
    }
}

Burada basit olarak “MyTodoStore.Product.GRPC” servisine bağlanabilmek için compiler tarafından oluşturulan “ProductGRPCServiceClient” client’ını inject ettik.

Ardından “ImportProductsAsync” method’u içerisinde CsvHelper paketini kullanarak CSV dosyasının parse işlemini gerçekleştirdik ve her bir ürünü “ProductGRPCServiceClient” client’ı üzerinden tek tek stream ederek server tarafına gönderdik.

Server’a stream işleminin tamamlandığını bildirebilmek için ise, aşağıdaki gibi stream’in “CompleteAsync” method’unu çağırdık.

await importProductStream.RequestStream.CompleteAsync();

Böylece streaming işlemi tamamlanmış olacak ve server ilgili channel’ı kapatarak geriye ne kadar ürün import edildiği bilgisini bize response olarak dönecektir.

Şimdi “Product” adında bir controller oluşturalım ve içerisinde CSV‘yi upload edebileceğimiz endpoint’i implemente edelim.

using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using MyTodoStore.SupplierProduct.API.Services;

namespace MyTodoStore.SupplierProduct.API.Controllers
{
    [ApiController]
    [Route("products")]
    public class ProductController : ControllerBase
    {
        private readonly ISupplierProductService _supplierProductService;

        public ProductController(ISupplierProductService supplierProductService)
        {
            _supplierProductService = supplierProductService;
        }

        [HttpPost]
        public async Task<IActionResult> UploadProducts(IFormFile formFile)
        {
            int importedProductCount = await _supplierProductService.ImportProductsAsync(formFile);

            return Ok($"{importedProductCount} products have been imported.");
        }
    }
}

Ardından “Startup” dosyası içerisinde aşağıdaki gibi gerekli servis injection işlemini ve gRPC client’ının kayıt işlemini gerçekleştirelim. gRPC client’ının kayıt işlemi için ise “Grpc.Net.ClientFactory” paketi ile gelen, “AddGrpcClient” method’unu kullanacağız.

Bu paket sayesinde gRPC client’larını merkezi bir noktadan configure edebilmekteyiz. Ayrıca bu paket, performanslı bir iletişim sağlayabilmemiz için channel’ların tekrar kullanılabilmesini otomatik olarak sağlamaktadır. Çünkü yeni bir channel’ın açılması demek, hem client hem de server tarafında bir network round-trip’inin yapılması demektir.

public void ConfigureServices(IServiceCollection services)
{

    services.AddControllers();
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "MyTodoStore.SupplierProduct.API", Version = "v1" });
    });

    services.AddScoped<ISupplierProductService, SupplierProductService>();

    services.AddGrpcClient<ProductGRPCService.ProductGRPCServiceClient>(o =>
    {
        o.Address = new Uri("http://localhost:5000");
    });
}

Son olarak API‘ı farklı bir port üzerinden erişime açmamız gerekmektedir. Çünkü default “5000” port’u, oluşturmuş olduğumuz gRPC servisi tarafından kullanılmaktadır. Bu API‘ı ise, “5001” portu üzerinden aşağıdaki gibi erişime açalım.

using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;

namespace MyTodoStore.SupplierProduct.API
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseUrls("https://*:5001");
                    webBuilder.UseStartup<Startup>();
                });
    }
}

Şimdi test etmeye hazırız.

Test Edelim

Öncelikle aşağıdaki gibi her iki projeyi de çalıştıralım.

Ardından “https://localhost:5001/swagger/index.html” endpoint’i üzerinden “MyTodoStore.SupplierProduct.API” ının ara yüzüne erişelim ve CSV dosyasını “/products” endpoint’i üzerinden upload edelim.

Bu işlemden beklentimiz ise, her bir ürünün “MyTodoStore.Product.GRPC” servisine stream edilerek gönderilmesi ve geriye ne kadar ürün aktarıldığı bilgisinin tek bir response üzerinden alınmasıdır.

Upload işlemi gerçekleştikten sonra terminal üzerinden “MyTodoStore.Product.GRPC” servisinin log’larına bir göz atalım.

Yukarıdaki terminal ekranına baktığımızda, ürünlerin sistem içerisine aktarılma işlemi sırasında log mesajları yazıldığını görebiliriz.

1 product has been imported. SKU: ABC Brand: Samsung
1 product has been imported. SKU: CDE Brand: Samsung
1 product has been imported. SKU: GDE Brand: Apple
Import products stream has been ended.

Ürünlerin sistem içerisine aktarılmasından sorumlu olan kod bloğunu ise hatırlayalım.

public override async Task<ImportProductResponse> ImportProductsStream(IAsyncStreamReader<ImportProductRequest> requestStream, ServerCallContext context)
{
    var importResponse = new ImportProductResponse();

    await foreach (var importProductItem in requestStream.ReadAllAsync())
    {
        // product import operations...

        importResponse.Count += 1;
        Console.WriteLine($"1 product has been imported. SKU: {importProductItem.Sku} Brand: {importProductItem.Brand}");
    }

    Console.WriteLine("Import products stream has been ended.");

    return importResponse;
}

Gördüğümüz gibi log mesajları “requestStream” i “foreach” loop’u içerisinde consume ederken yazdırılmaktadır. Gelen stream client tarafından sona erdirildiğinde ise terminal ekranından da görebileceğimiz üzere “Import products stream has been ended.” log mesajı yazdırılmaktadır.

Kısacası ürünlerin server’a stream işleminin, client hala ürünlerin üzerinde çalışıyorken server tarafından async bir şekilde ele alındığını ve stream işlemi tamamlandığında geriye ne kadar ürün aktarıldığı bilgisini içeren “ImportProductResponse” message’ı dönüldüğünü görebilmekteyiz.

Resiliency

Servisler arası yüksek performanslı bir iletişim sağlayabilmek her ne kadar önemliyse, geçici hatalara karşı hazırlıklı olabilmekte bi o kadar önemlidir. gRPC dünyasında da network kaynaklı hatalara veya ilgili servislerin geçici bir süre kullanılamaz olması gibi durumlara karşı hazırlıklı olmalıyız.

Neyseki hataya dayanıklı gRPC uygulamaları geliştirebilmek için herhangi bir custom çözüm üretmeden gRPC retries özelliğinden yararlanabilmekteyiz.

Bu özellikten yararlanabilmek için gRPC client’ını, client tarafında kayıt ederken temel olarak aşağıdaki gibi bir logic’e sahip olmamız gerekmektedir. Böylece merkezi düzeyde ilgili client için retry özelliğine sahip olabilmekteyiz.

var retryMethodConfig = new MethodConfig
{
    Names = { MethodName.Default },
    RetryPolicy = new RetryPolicy
    {
        MaxAttempts = 5,
        InitialBackoff = TimeSpan.FromSeconds(1),
        MaxBackoff = TimeSpan.FromSeconds(5),
        BackoffMultiplier = 1.5,
        RetryableStatusCodes = { StatusCode.Unavailable }
    }
};

services.AddGrpcClient<ProductGRPCService.ProductGRPCServiceClient>(o =>
{
    o.Address = new Uri("http://localhost:5000");
    o.ChannelOptionsActions.Add(opt => opt.ServiceConfig = new ServiceConfig { MethodConfigs = { retryMethodConfig } });
});

Bu konu hakkındaki daha detaylı bilgilere ise, buradan erişebilirsiniz.

Toparlayalım

gRPC, Google tarafından design edilmiş, servisler arasında yüksek performanslı bir iletişim kurabilmemize olanak sağlayan harika bir RPC framework’üdür. “product.proto” dosyasında tanımlamış olduğumuz gibi contractlar bir proto dosyası olarak tanımlanmaktadır. Bu proto dosyaları ise hem server hem de client tarafı için gerekli olan gRPC altyapısının oluşturulabilmesi ve iletişimi için kullanılmaktadır.

gRPC, özellikle .NET 5 ile yapılan iyileştirme ve optimizasyonlardan sonra oldukça harika bir iletişim seçeneği haline gelmektedir. Inter microservice iletişimi için gRPC kullanarak, iletişim sürecini daha efektif, performanslı ve asynchronous bir hale getirebilmek mümkündür.

Ayrıca gRPC, “authentication”, “load balancing” ve “health checking” gibi özellikleri de desteklemektedir.

Source: GokGokalp/mytodostore-net5-grpc-client-streaming: mytodostore-net5-grpc-client-streaming (github.com)

Referanslar

Core concepts, architecture and lifecycle | gRPC
Introduction to gRPC on .NET | Microsoft Docs

Exit mobile version