diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CallBenchmarks.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CallBenchmarks.cs new file mode 100644 index 000000000..5ba4159db --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CallBenchmarks.cs @@ -0,0 +1,88 @@ +using System.Net.Http.Json; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using GrpcVsRest.Server; +using Microsoft.AspNetCore.Builder; + +namespace GrpcVsRest.Benchmarks; + +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class CallBenchmarks +{ + private WebApplication _app = null!; + private CatalogClients _clients = null!; + + [GlobalSetup] + public async Task Setup() + { + _app = await CatalogHost.StartAsync(); + _clients = new CatalogClients(); + } + + [GlobalCleanup] + public async Task Cleanup() + { + _clients.Dispose(); + await _app.DisposeAsync(); + } + + // One product + [Benchmark(Baseline = true), BenchmarkCategory("1 product")] + public Task RestHttp1() => _clients.RestHttp1.GetFromJsonAsync("/api/products/42"); + + [Benchmark, BenchmarkCategory("1 product")] + public Task RestHttp2() => _clients.RestHttp2.GetFromJsonAsync("/api/products/42"); + + [Benchmark, BenchmarkCategory("1 product")] + public Task TranscodedJson() => _clients.Transcoded.GetFromJsonAsync("/v1/products/42"); + + [Benchmark, BenchmarkCategory("1 product")] + public async Task Grpc() => await _clients.Grpc.GetProductAsync(new GetProductRequest { Id = 42 }); + + [Benchmark, BenchmarkCategory("1 product")] + public async Task GrpcWeb() => await _clients.GrpcWeb.GetProductAsync(new GetProductRequest { Id = 42 }); + + // 100 products + [Benchmark(Baseline = true), BenchmarkCategory("100 products")] + public Task?> ListRestHttp1() => + _clients.RestHttp1.GetFromJsonAsync>("/api/products"); + + [Benchmark, BenchmarkCategory("100 products")] + public Task?> ListRestHttp1Gzip() => + _clients.RestHttp1Gzip.GetFromJsonAsync>("/api/products"); + + [Benchmark, BenchmarkCategory("100 products")] + public Task?> ListRestHttp2() => + _clients.RestHttp2.GetFromJsonAsync>("/api/products"); + + [Benchmark, BenchmarkCategory("100 products")] + public Task ListTranscodedJson() => + _clients.Transcoded.GetFromJsonAsync("/v1/products"); + + [Benchmark, BenchmarkCategory("100 products")] + public async Task ListGrpc() => await _clients.Grpc.ListProductsAsync(new ListProductsRequest()); + + [Benchmark, BenchmarkCategory("100 products")] + public async Task ListGrpcWeb() => await _clients.GrpcWeb.ListProductsAsync(new ListProductsRequest()); + + // 100 concurrent calls for one product each + [Benchmark(Baseline = true), BenchmarkCategory("100 parallel calls")] + public Task ParallelRestHttp1() => Parallel100(() => RestHttp1()); + + [Benchmark, BenchmarkCategory("100 parallel calls")] + public Task ParallelRestHttp2() => Parallel100(() => RestHttp2()); + + [Benchmark, BenchmarkCategory("100 parallel calls")] + public Task ParallelTranscodedJson() => Parallel100(() => TranscodedJson()); + + [Benchmark, BenchmarkCategory("100 parallel calls")] + public Task ParallelGrpc() => Parallel100(() => Grpc()); + + [Benchmark, BenchmarkCategory("100 parallel calls")] + public Task ParallelGrpcWeb() => Parallel100(() => GrpcWeb()); + + private static Task Parallel100(Func call) => + Task.WhenAll(Enumerable.Range(0, 100).Select(_ => call())); +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CatalogClients.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CatalogClients.cs new file mode 100644 index 000000000..86f237676 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CatalogClients.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Net.Sockets; +using Grpc.Net.Client; +using Grpc.Net.Client.Web; +using GrpcVsRest.Server; + +namespace GrpcVsRest.Benchmarks; + +// One reused client per way of calling the catalog. +// Pass a ByteCounter to count every byte each client writes to and reads from its sockets. +public sealed class CatalogClients : IDisposable +{ + public CatalogClients(Func? counterFor = null) + { + counterFor ??= _ => null; + + RestHttp1 = new HttpClient(Handler(counterFor("REST, HTTP/1.1"))) + { + BaseAddress = new Uri(CatalogHost.Http1Address) + }; + + RestHttp1Gzip = new HttpClient(Handler(counterFor("REST, HTTP/1.1, gzip"), DecompressionMethods.GZip)) + { + BaseAddress = new Uri(CatalogHost.Http1Address) + }; + + RestHttp2 = new HttpClient(Handler(counterFor("REST, HTTP/2"))) + { + BaseAddress = new Uri(CatalogHost.Http2Address), + DefaultRequestVersion = HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + + Transcoded = new HttpClient(Handler(counterFor("Transcoded JSON, HTTP/1.1"))) + { + BaseAddress = new Uri(CatalogHost.Http1Address) + }; + + _grpcChannel = GrpcChannel.ForAddress(CatalogHost.Http2Address, + new GrpcChannelOptions { HttpHandler = Handler(counterFor("gRPC, HTTP/2")) }); + Grpc = new Catalog.CatalogClient(_grpcChannel); + + _grpcWebChannel = GrpcChannel.ForAddress(CatalogHost.Http1Address, + new GrpcChannelOptions + { + HttpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, Handler(counterFor("gRPC-Web, HTTP/1.1"))), + HttpVersion = HttpVersion.Version11 + }); + GrpcWeb = new Catalog.CatalogClient(_grpcWebChannel); + } + + public HttpClient RestHttp1 { get; } + public HttpClient RestHttp1Gzip { get; } + public HttpClient RestHttp2 { get; } + public HttpClient Transcoded { get; } + public Catalog.CatalogClient Grpc { get; } + public Catalog.CatalogClient GrpcWeb { get; } + + private readonly GrpcChannel _grpcChannel; + private readonly GrpcChannel _grpcWebChannel; + + public void Dispose() + { + RestHttp1.Dispose(); + RestHttp1Gzip.Dispose(); + RestHttp2.Dispose(); + Transcoded.Dispose(); + _grpcChannel.Dispose(); + _grpcWebChannel.Dispose(); + } + + private static SocketsHttpHandler Handler(ByteCounter? counter, + DecompressionMethods decompression = DecompressionMethods.None) + { + // EnableMultipleHttp2Connections matches what GrpcChannel sets on the handler it creates itself. + var handler = new SocketsHttpHandler + { + AutomaticDecompression = decompression, + EnableMultipleHttp2Connections = true + }; + + if (counter is not null) + { + handler.ConnectCallback = async (context, cancellationToken) => + { + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + await socket.ConnectAsync(context.DnsEndPoint, cancellationToken); + counter.AddConnection(); + + return new CountingStream(new NetworkStream(socket, ownsSocket: true), counter); + }; + } + + return handler; + } +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CatalogHost.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CatalogHost.cs new file mode 100644 index 000000000..e75499a18 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CatalogHost.cs @@ -0,0 +1,44 @@ +using GrpcVsRest.Server; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Logging; + +namespace GrpcVsRest.Benchmarks; + +// Starts the same catalog server in this process, on loopback, with the ports the +// server's appsettings.json uses: 5100 for HTTP/1.1 and 5101 for cleartext HTTP/2. +public static class CatalogHost +{ + public const string Http1Address = "http://localhost:5100"; + public const string Http2Address = "http://localhost:5101"; + + public static async Task StartAsync( + Action? extraEndpoints = null, bool logWarnings = false) + { + var builder = WebApplication.CreateBuilder(); + // The server's appsettings.json is copied next to this exe; its Kestrel endpoints would bind 5100 a second time. + builder.Configuration.Sources.Clear(); + + builder.Logging.ClearProviders(); + if (logWarnings) + { + builder.Logging.AddConsole().SetMinimumLevel(LogLevel.Warning); + } + + builder.WebHost.ConfigureKestrel(kestrel => + { + kestrel.ListenLocalhost(5100, listen => listen.Protocols = HttpProtocols.Http1); + kestrel.ListenLocalhost(5101, listen => listen.Protocols = HttpProtocols.Http2); + extraEndpoints?.Invoke(kestrel); + }); + + builder.Services.AddCatalog(); + + var app = builder.Build(); + app.UseCatalog(); + await app.StartAsync(); + + return app; + } +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CountingStream.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CountingStream.cs new file mode 100644 index 000000000..3463ecae0 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CountingStream.cs @@ -0,0 +1,84 @@ +namespace GrpcVsRest.Benchmarks; + +public sealed class ByteCounter +{ + private long _sent; + private long _received; + private int _connections; + + public long Sent => Interlocked.Read(ref _sent); + public long Received => Interlocked.Read(ref _received); + public int Connections => Volatile.Read(ref _connections); + + public void AddConnection() => Interlocked.Increment(ref _connections); + public void AddSent(int count) => Interlocked.Add(ref _sent, count); + public void AddReceived(int count) => Interlocked.Add(ref _received, count); + + public void Reset() + { + Interlocked.Exchange(ref _sent, 0); + Interlocked.Exchange(ref _received, 0); + } +} + +// Wraps the socket stream and counts the bytes HttpClient writes and reads: +// request and response headers, HTTP/2 frames and bodies (TCP/IP headers are not included). +public sealed class CountingStream(Stream inner, ByteCounter counter) : Stream +{ + public override bool CanRead => inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => inner.CanWrite; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + var read = inner.Read(buffer, offset, count); + counter.AddReceived(read); + return read; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var read = await inner.ReadAsync(buffer, cancellationToken); + counter.AddReceived(read); + return read; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override void Write(byte[] buffer, int offset, int count) + { + inner.Write(buffer, offset, count); + counter.AddSent(count); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await inner.WriteAsync(buffer, cancellationToken); + counter.AddSent(buffer.Length); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override void Flush() => inner.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => inner.FlushAsync(cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Demo.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Demo.cs new file mode 100644 index 000000000..99b326981 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Demo.cs @@ -0,0 +1,130 @@ +using System.Net; +using Grpc.Core; +using Grpc.Net.Client; +using Grpc.Net.Client.Web; +using GrpcVsRest.Server; +using Microsoft.AspNetCore.Server.Kestrel.Core; + +namespace GrpcVsRest.Benchmarks; + +// Calls the same product every way the server offers it and prints what comes back. +public static class Demo +{ + public static async Task RunAsync() + { + // Port 5102 allows HTTP/1.1 and HTTP/2 without TLS, only to show what Kestrel does with it. + await using var app = await CatalogHost.StartAsync( + kestrel => kestrel.ListenLocalhost(5102, listen => listen.Protocols = HttpProtocols.Http1AndHttp2), + logWarnings: true); + + using var rest = new HttpClient { BaseAddress = new Uri("http://localhost:5100") }; + Console.WriteLine("REST: " + await rest.GetStringAsync("/api/products/7")); + Console.WriteLine("Transcoded: " + await rest.GetStringAsync("/v1/products/7")); + + using var channel = GrpcChannel.ForAddress("http://localhost:5101"); + var catalog = new Catalog.CatalogClient(channel); + var product = await catalog.GetProductAsync(new GetProductRequest { Id = 7 }, + deadline: DateTime.UtcNow.AddSeconds(5)); + Console.WriteLine("gRPC: " + product); + + using var webChannel = GrpcChannel.ForAddress("http://localhost:5100", new GrpcChannelOptions + { + HttpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()), + HttpVersion = HttpVersion.Version11 + }); + var webCatalog = new Catalog.CatalogClient(webChannel); + Console.WriteLine("gRPC-Web: " + await webCatalog.GetProductAsync(new GetProductRequest { Id = 7 })); + + Console.WriteLine(); + Console.WriteLine("Server streaming over gRPC:"); + using (var call = catalog.WatchStock(new WatchStockRequest { Id = 7, Updates = 3 })) + { + await foreach (var update in call.ResponseStream.ReadAllAsync()) + { + Console.WriteLine($" {DateTime.Now:HH:mm:ss.fff} {update}"); + } + } + + Console.WriteLine("Server streaming over gRPC-Web (HTTP/1.1):"); + using (var call = webCatalog.WatchStock(new WatchStockRequest { Id = 7, Updates = 3 })) + { + await foreach (var update in call.ResponseStream.ReadAllAsync()) + { + Console.WriteLine($" {DateTime.Now:HH:mm:ss.fff} {update}"); + } + } + + Console.WriteLine("Server streaming through transcoding (raw body):"); + Console.Write(await rest.GetStringAsync("/v1/products/7/stock?updates=3")); + + Console.WriteLine(); + Console.WriteLine("Not found, three ways:"); + var restMissing = await rest.GetAsync("/api/products/999"); + Console.WriteLine($" REST: {(int)restMissing.StatusCode} {restMissing.StatusCode}"); + var transcodedMissing = await rest.GetAsync("/v1/products/999"); + Console.WriteLine($" Transcoded: {(int)transcodedMissing.StatusCode} {transcodedMissing.StatusCode} " + + await transcodedMissing.Content.ReadAsStringAsync()); + try + { + await catalog.GetProductAsync(new GetProductRequest { Id = 999 }); + } + catch (RpcException ex) + { + Console.WriteLine($" gRPC: {ex.StatusCode} \"{ex.Status.Detail}\""); + } + + Console.WriteLine(); + Console.WriteLine("Cleartext HTTP/2 probes:"); + await ProbeAsync("HttpClient defaults -> HTTP/2-only endpoint", + new HttpClient(), "http://localhost:5101"); + await ProbeAsync("Version 2.0, RequestVersionOrLower -> HTTP/2-only endpoint", + new HttpClient { DefaultRequestVersion = HttpVersion.Version20 }, "http://localhost:5101"); + await ProbeAsync("Version 2.0, RequestVersionExact -> HTTP/2-only endpoint", + new HttpClient + { + DefaultRequestVersion = HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact + }, "http://localhost:5101"); + await ProbeAsync("Version 2.0, RequestVersionExact -> HTTP/1.1 and HTTP/2 endpoint", + new HttpClient + { + DefaultRequestVersion = HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact + }, "http://localhost:5102"); + + await GrpcProbeAsync("gRPC client -> HTTP/1.1-only endpoint", "http://localhost:5100"); + await GrpcProbeAsync("gRPC client -> HTTP/1.1 and HTTP/2 endpoint", "http://localhost:5102"); + await GrpcProbeAsync("gRPC client -> HTTP/2-only endpoint", "http://localhost:5101"); + } + + private static async Task ProbeAsync(string name, HttpClient client, string address) + { + using (client) + { + try + { + using var response = await client.GetAsync($"{address}/api/products/7"); + Console.WriteLine($" {name}: {(int)response.StatusCode}, HTTP/{response.Version}"); + } + catch (HttpRequestException ex) + { + Console.WriteLine($" {name}: {ex.GetType().Name}: {ex.Message}"); + } + } + } + + private static async Task GrpcProbeAsync(string name, string address) + { + using var channel = GrpcChannel.ForAddress(address); + try + { + var product = await new Catalog.CatalogClient(channel) + .GetProductAsync(new GetProductRequest { Id = 7 }, deadline: DateTime.UtcNow.AddSeconds(5)); + Console.WriteLine($" {name}: OK, {product.Name}"); + } + catch (RpcException ex) + { + Console.WriteLine($" {name}: {ex.StatusCode}, {ex.Status.DebugException?.Message}"); + } + } +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/GrpcVsRest.Benchmarks.csproj b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/GrpcVsRest.Benchmarks.csproj new file mode 100644 index 000000000..d3f95af38 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/GrpcVsRest.Benchmarks.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/PayloadSizes.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/PayloadSizes.cs new file mode 100644 index 000000000..c45e40b56 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/PayloadSizes.cs @@ -0,0 +1,33 @@ +using System.IO.Compression; +using System.Text.Json; +using Google.Protobuf; + +namespace GrpcVsRest.Benchmarks; + +public static class PayloadSizes +{ + public static void Print() + { + Console.WriteLine($"{"Shape",-32}{"JSON",8}{"JSON gzip",11}{"Protobuf",10}{"Proto gzip",12}"); + + foreach (var shape in Shapes.All()) + { + var json = JsonSerializer.SerializeToUtf8Bytes(shape.Dto, shape.Dto.GetType(), Shapes.JsonOptions); + var protobuf = shape.Message.ToByteArray(); + + Console.WriteLine($"{shape.Name,-32}{json.Length,8:N0}{Gzip(json),11:N0}" + + $"{protobuf.Length,10:N0}{Gzip(protobuf),12:N0}"); + } + } + + private static int Gzip(byte[] bytes) + { + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true)) + { + gzip.Write(bytes); + } + + return (int)output.Length; + } +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Program.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Program.cs new file mode 100644 index 000000000..07e3d0c01 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Program.cs @@ -0,0 +1,18 @@ +using BenchmarkDotNet.Running; +using GrpcVsRest.Benchmarks; + +switch (args) +{ + case ["demo"]: + await Demo.RunAsync(); + break; + case ["sizes"]: + PayloadSizes.Print(); + break; + case ["wire"]: + await WireBytes.PrintAsync(); + break; + default: + BenchmarkSwitcher.FromAssembly(typeof(CallBenchmarks).Assembly).Run(args); + break; +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Protos/shapes.proto b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Protos/shapes.proto new file mode 100644 index 000000000..bfac8a6b5 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Protos/shapes.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +option csharp_namespace = "GrpcVsRest.Benchmarks"; + +package shapes; + +message Reading { + int32 sensor_id = 1; + int64 timestamp = 2; + double temperature = 3; + double humidity = 4; + double pressure = 5; +} + +message ReadingList { + repeated Reading readings = 1; +} + +message Review { + int32 id = 1; + int32 product_id = 2; + string author = 3; + string text = 4; +} + +message ReviewList { + repeated Review reviews = 1; +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/SerializationBenchmarks.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/SerializationBenchmarks.cs new file mode 100644 index 000000000..8f1557980 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/SerializationBenchmarks.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using Google.Protobuf; +using GrpcVsRest.Server; + +namespace GrpcVsRest.Benchmarks; + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(List))] +public partial class CatalogJsonContext : JsonSerializerContext; + +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class SerializationBenchmarks +{ + private List _products = []; + private ProductList _productList = new(); + private byte[] _json = []; + private byte[] _protobuf = []; + + [GlobalSetup] + public void Setup() + { + (_products, _productList) = Shapes.Catalog(); + _json = JsonSerializer.SerializeToUtf8Bytes(_products, Shapes.JsonOptions); + _protobuf = _productList.ToByteArray(); + } + + [Benchmark(Baseline = true), BenchmarkCategory("Serialize")] + public byte[] SerializeJson() => JsonSerializer.SerializeToUtf8Bytes(_products, Shapes.JsonOptions); + + [Benchmark, BenchmarkCategory("Serialize")] + public byte[] SerializeJsonSourceGen() => + JsonSerializer.SerializeToUtf8Bytes(_products, CatalogJsonContext.Default.ListProductDto); + + [Benchmark, BenchmarkCategory("Serialize")] + public byte[] SerializeProtobuf() => _productList.ToByteArray(); + + [Benchmark(Baseline = true), BenchmarkCategory("Deserialize")] + public List? DeserializeJson() => + JsonSerializer.Deserialize>(_json, Shapes.JsonOptions); + + [Benchmark, BenchmarkCategory("Deserialize")] + public List? DeserializeJsonSourceGen() => + JsonSerializer.Deserialize(_json, CatalogJsonContext.Default.ListProductDto); + + [Benchmark, BenchmarkCategory("Deserialize")] + public ProductList DeserializeProtobuf() => ProductList.Parser.ParseFrom(_protobuf); +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Shapes.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Shapes.cs new file mode 100644 index 000000000..8ddd1089d --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/Shapes.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using Google.Protobuf; +using GrpcVsRest.Server; + +namespace GrpcVsRest.Benchmarks; + +public record ReadingDto(int SensorId, long Timestamp, double Temperature, double Humidity, double Pressure); + +public record ReviewDto(int Id, int ProductId, string Author, string Text); + +public record Shape(string Name, object Dto, IMessage Message); + +public static class Shapes +{ + public static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + // Review text is built from random words, so the reviews differ the way real ones do + // and gzip cannot cheat by finding the same sentence a hundred times. + private static readonly string[] Words = + ("the a and it works well after two days weeks months of daily use build quality cable desk " + + "keys feel light bright status small large box arrived early late price good great fine poor " + + "would buy again office home colleague upgrade replace battery charge fast slow screen sound " + + "clear loud quiet setup easy hard driver update windows linux mac laptop monitor port plug " + + "stand hub mouse keyboard webcam picture sharp blurry color warm cold metal plastic heavy " + + "stable wobbly support answered quickly refund return shipping packaging damaged perfect " + + "but not very really quite still never always sometimes only also too much more less than " + + "my our their for with without on in at from to by under over before during because so") + .Split(' '); + + private static string ReviewText(Random random, int words) => + string.Join(' ', Enumerable.Range(0, words).Select(_ => Words[random.Next(Words.Length)])); + + public static (List Dto, ProductList Message) Catalog() + { + var dto = new ProductStore().All.ToList(); + var message = new ProductList(); + message.Products.AddRange(dto.Select(p => new Product + { + Id = p.Id, Name = p.Name, Category = p.Category, Price = p.Price, Stock = p.Stock + })); + + return (dto, message); + } + + public static (List Dto, ReadingList Message) Readings() + { + var random = new Random(42); + var start = new DateTimeOffset(2026, 10, 1, 8, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds(); + var dto = Enumerable.Range(0, 100) + .Select(i => new ReadingDto( + SensorId: 1 + i % 8, + Timestamp: start + i * 1000L, + Temperature: Math.Round(18 + random.NextDouble() * 8, 2), + Humidity: Math.Round(35 + random.NextDouble() * 30, 2), + Pressure: Math.Round(990 + random.NextDouble() * 40, 2))) + .ToList(); + + var message = new ReadingList(); + message.Readings.AddRange(dto.Select(r => new Reading + { + SensorId = r.SensorId, Timestamp = r.Timestamp, Temperature = r.Temperature, + Humidity = r.Humidity, Pressure = r.Pressure + })); + + return (dto, message); + } + + public static (List Dto, ReviewList Message) Reviews() + { + var random = new Random(42); + var dto = Enumerable.Range(1, 100) + .Select(i => new ReviewDto(i, 1 + i % 20, $"Customer {i}", ReviewText(random, 30 + random.Next(30)))) + .ToList(); + + var message = new ReviewList(); + message.Reviews.AddRange(dto.Select(r => new Review + { + Id = r.Id, ProductId = r.ProductId, Author = r.Author, Text = r.Text + })); + + return (dto, message); + } + + public static IEnumerable All() + { + var catalog = Catalog(); + var readings = Readings(); + var reviews = Reviews(); + + yield return new Shape("100 products (mixed)", catalog.Dto, catalog.Message); + yield return new Shape("100 sensor readings (numbers)", readings.Dto, readings.Message); + yield return new Shape("100 reviews (text)", reviews.Dto, reviews.Message); + } +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/WireBytes.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/WireBytes.cs new file mode 100644 index 000000000..9d426846d --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/WireBytes.cs @@ -0,0 +1,83 @@ +using System.Net.Http.Json; +using GrpcVsRest.Server; + +namespace GrpcVsRest.Benchmarks; + +// Measures bytes on the wire per call, after a warm-up, over reused connections, +// and counts the connections each client opens for 100 concurrent calls. +public static class WireBytes +{ + private const int Calls = 100; + + public static async Task PrintAsync() + { + await using var app = await CatalogHost.StartAsync(); + + var counters = new Dictionary(); + using var clients = new CatalogClients(name => counters[name] = new ByteCounter()); + + await PrintPerCallAsync("One product (GetProduct), average per call", SingleProductCalls(clients), counters); + await PrintPerCallAsync("100 products (ListProducts), average per call", ProductListCalls(clients), counters); + + // Fresh clients, so every connection they open is counted. + var parallelCounters = new Dictionary(); + using var parallelClients = new CatalogClients(name => parallelCounters[name] = new ByteCounter()); + + Console.WriteLine(); + Console.WriteLine("100 concurrent GetProduct calls on fresh clients, connections opened:"); + foreach (var (name, call) in SingleProductCalls(parallelClients)) + { + await Task.WhenAll(Enumerable.Range(0, Calls).Select(_ => call())); + Console.WriteLine($" {name,-28}{parallelCounters[name].Connections,4}"); + } + } + + private static Dictionary> SingleProductCalls(CatalogClients clients) => new() + { + ["REST, HTTP/1.1"] = () => clients.RestHttp1.GetFromJsonAsync("/api/products/42"), + ["REST, HTTP/1.1, gzip"] = () => clients.RestHttp1Gzip.GetFromJsonAsync("/api/products/42"), + ["REST, HTTP/2"] = () => clients.RestHttp2.GetFromJsonAsync("/api/products/42"), + ["Transcoded JSON, HTTP/1.1"] = () => clients.Transcoded.GetFromJsonAsync("/v1/products/42"), + ["gRPC, HTTP/2"] = async () => await clients.Grpc.GetProductAsync(new GetProductRequest { Id = 42 }), + ["gRPC-Web, HTTP/1.1"] = async () => await clients.GrpcWeb.GetProductAsync(new GetProductRequest { Id = 42 }) + }; + + private static Dictionary> ProductListCalls(CatalogClients clients) => new() + { + ["REST, HTTP/1.1"] = () => clients.RestHttp1.GetFromJsonAsync>("/api/products"), + ["REST, HTTP/1.1, gzip"] = () => clients.RestHttp1Gzip.GetFromJsonAsync>("/api/products"), + ["REST, HTTP/2"] = () => clients.RestHttp2.GetFromJsonAsync>("/api/products"), + ["Transcoded JSON, HTTP/1.1"] = () => clients.Transcoded.GetFromJsonAsync("/v1/products"), + ["gRPC, HTTP/2"] = async () => await clients.Grpc.ListProductsAsync(new ListProductsRequest()), + ["gRPC-Web, HTTP/1.1"] = async () => await clients.GrpcWeb.ListProductsAsync(new ListProductsRequest()) + }; + + private static async Task PrintPerCallAsync(string title, Dictionary> calls, + Dictionary counters) + { + Console.WriteLine(); + Console.WriteLine($"{title}:"); + Console.WriteLine($" {"Client",-28}{"Sent",8}{"Received",10}{"Total",8}"); + + foreach (var (name, call) in calls) + { + for (var i = 0; i < 10; i++) + { + await call(); + } + + counters[name].Reset(); + + for (var i = 0; i < Calls; i++) + { + await call(); + } + + var sent = counters[name].Sent / Calls; + var received = counters[name].Received / Calls; + Console.WriteLine($" {name,-28}{sent,8:N0}{received,10:N0}{sent + received,8:N0}"); + } + } +} + +public record TranscodedList(List Products); diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/CatalogService.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/CatalogService.cs new file mode 100644 index 000000000..e5ff5bbda --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/CatalogService.cs @@ -0,0 +1,44 @@ +using Grpc.Core; + +namespace GrpcVsRest.Server; + +public class CatalogService(ProductStore store) : Catalog.CatalogBase +{ + public override Task GetProduct(GetProductRequest request, ServerCallContext context) + { + var product = store.Find(request.Id) + ?? throw new RpcException(new Status(StatusCode.NotFound, $"Product {request.Id} not found")); + + return Task.FromResult(ToMessage(product)); + } + + public override Task ListProducts(ListProductsRequest request, ServerCallContext context) + { + var list = new ProductList(); + list.Products.AddRange(store.All.Select(ToMessage)); + + return Task.FromResult(list); + } + + public override async Task WatchStock(WatchStockRequest request, + IServerStreamWriter responseStream, ServerCallContext context) + { + var product = store.Find(request.Id) + ?? throw new RpcException(new Status(StatusCode.NotFound, $"Product {request.Id} not found")); + + for (var i = 0; i < request.Updates; i++) + { + await responseStream.WriteAsync(new StockUpdate { Id = product.Id, Stock = product.Stock - i }); + await Task.Delay(100, context.CancellationToken); + } + } + + private static Product ToMessage(ProductDto product) => new() + { + Id = product.Id, + Name = product.Name, + Category = product.Category, + Price = product.Price, + Stock = product.Stock + }; +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/CatalogSetup.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/CatalogSetup.cs new file mode 100644 index 000000000..1510b0a9b --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/CatalogSetup.cs @@ -0,0 +1,28 @@ +namespace GrpcVsRest.Server; + +public static class CatalogSetup +{ + public static IServiceCollection AddCatalog(this IServiceCollection services) + { + services.AddSingleton(); + services.AddGrpc().AddJsonTranscoding(); + services.AddResponseCompression(); + + return services; + } + + public static WebApplication UseCatalog(this WebApplication app) + { + app.UseResponseCompression(); + app.UseGrpcWeb(); + + app.MapGet("/api/products/{id:int}", (int id, ProductStore store) => + store.Find(id) is { } product ? Results.Ok(product) : Results.NotFound()); + + app.MapGet("/api/products", (ProductStore store) => store.All); + + app.MapGrpcService().EnableGrpcWeb(); + + return app; + } +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/GrpcVsRest.Server.csproj b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/GrpcVsRest.Server.csproj new file mode 100644 index 000000000..4aeff12cd --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/GrpcVsRest.Server.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/ProductStore.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/ProductStore.cs new file mode 100644 index 000000000..bf3516bce --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/ProductStore.cs @@ -0,0 +1,22 @@ +namespace GrpcVsRest.Server; + +public record ProductDto(int Id, string Name, string Category, double Price, int Stock); + +public class ProductStore +{ + private static readonly string[] Items = + ["Mechanical Keyboard", "Wireless Mouse", "USB-C Hub", "Laptop Stand", "Webcam"]; + + private static readonly string[] Categories = ["Peripherals", "Accessories", "Video"]; + + public IReadOnlyList All { get; } = Enumerable.Range(1, 100) + .Select(id => new ProductDto( + id, + $"{Items[id % Items.Length]} {id}", + Categories[id % Categories.Length], + Math.Round(id + 0.99, 2), + id * 3)) + .ToList(); + + public ProductDto? Find(int id) => All.FirstOrDefault(p => p.Id == id); +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/Program.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/Program.cs new file mode 100644 index 000000000..1f5894418 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/Program.cs @@ -0,0 +1,11 @@ +using GrpcVsRest.Server; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddCatalog(); + +var app = builder.Build(); + +app.UseCatalog(); + +app.Run(); diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/Protos/catalog.proto b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/Protos/catalog.proto new file mode 100644 index 000000000..04407b65f --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/Protos/catalog.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +option csharp_namespace = "GrpcVsRest.Server"; + +package catalog; + +import "google/api/annotations.proto"; + +service Catalog { + rpc GetProduct (GetProductRequest) returns (Product) { + option (google.api.http) = { + get: "/v1/products/{id}" + }; + } + + rpc ListProducts (ListProductsRequest) returns (ProductList) { + option (google.api.http) = { + get: "/v1/products" + }; + } + + rpc WatchStock (WatchStockRequest) returns (stream StockUpdate) { + option (google.api.http) = { + get: "/v1/products/{id}/stock" + }; + } +} + +message GetProductRequest { + int32 id = 1; +} + +message ListProductsRequest { +} + +message Product { + int32 id = 1; + string name = 2; + string category = 3; + double price = 4; + int32 stock = 5; +} + +message ProductList { + repeated Product products = 1; +} + +message WatchStockRequest { + int32 id = 1; + int32 updates = 2; +} + +message StockUpdate { + int32 id = 1; + int32 stock = 2; +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/appsettings.json b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/appsettings.json new file mode 100644 index 000000000..0a13e3704 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Server/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http1": { + "Url": "http://localhost:5100", + "Protocols": "Http1" + }, + "Http2": { + "Url": "http://localhost:5101", + "Protocols": "Http2" + } + } + } +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Tests/CatalogTests.cs b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Tests/CatalogTests.cs new file mode 100644 index 000000000..65ec94fae --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Tests/CatalogTests.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Http.Json; +using Grpc.Core; +using Grpc.Net.Client; +using GrpcVsRest.Server; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace GrpcVsRest.Tests; + +public class CatalogTests(WebApplicationFactory factory) : IClassFixture> +{ + private readonly CancellationToken _cancellationToken = TestContext.Current.CancellationToken; + + [Fact] + public async Task GivenSameId_WhenCalledThreeWays_ThenAllReturnTheSameProduct() + { + var http = factory.CreateClient(); + var rest = await http.GetFromJsonAsync("/api/products/7", _cancellationToken); + var transcoded = await http.GetFromJsonAsync("/v1/products/7", _cancellationToken); + + using var channel = GrpcChannel.ForAddress(factory.Server.BaseAddress, + new GrpcChannelOptions { HttpHandler = factory.Server.CreateHandler() }); + var grpc = await new Catalog.CatalogClient(channel) + .GetProductAsync(new GetProductRequest { Id = 7 }, cancellationToken: _cancellationToken); + + Assert.NotNull(rest); + Assert.Equal(rest, transcoded); + Assert.Equal(rest, new ProductDto(grpc.Id, grpc.Name, grpc.Category, grpc.Price, grpc.Stock)); + } + + [Fact] + public async Task GivenUnknownId_WhenCalled_ThenRestReturns404AndGrpcReturnsNotFound() + { + var http = factory.CreateClient(); + var rest = await http.GetAsync("/api/products/999", _cancellationToken); + var transcoded = await http.GetAsync("/v1/products/999", _cancellationToken); + + using var channel = GrpcChannel.ForAddress(factory.Server.BaseAddress, + new GrpcChannelOptions { HttpHandler = factory.Server.CreateHandler() }); + var grpc = await Assert.ThrowsAsync(() => new Catalog.CatalogClient(channel) + .GetProductAsync(new GetProductRequest { Id = 999 }, cancellationToken: _cancellationToken) + .ResponseAsync); + + Assert.Equal(HttpStatusCode.NotFound, rest.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, transcoded.StatusCode); + Assert.Equal(StatusCode.NotFound, grpc.StatusCode); + } +} diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Tests/GrpcVsRest.Tests.csproj b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Tests/GrpcVsRest.Tests.csproj new file mode 100644 index 000000000..762b75259 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Tests/GrpcVsRest.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + diff --git a/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.sln b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.sln new file mode 100644 index 000000000..0c6cc9c50 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/GrpcVsRest.sln @@ -0,0 +1,62 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GrpcVsRest.Server", "GrpcVsRest.Server\GrpcVsRest.Server.csproj", "{223BC484-DA6F-4217-8C19-E6A08095E13F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GrpcVsRest.Benchmarks", "GrpcVsRest.Benchmarks\GrpcVsRest.Benchmarks.csproj", "{1302261A-5854-4084-85F6-2C82BDBCC50A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GrpcVsRest.Tests", "GrpcVsRest.Tests\GrpcVsRest.Tests.csproj", "{5B0661DF-83A1-457F-A6E3-D70E4128048A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Debug|x64.ActiveCfg = Debug|Any CPU + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Debug|x64.Build.0 = Debug|Any CPU + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Debug|x86.ActiveCfg = Debug|Any CPU + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Debug|x86.Build.0 = Debug|Any CPU + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Release|Any CPU.Build.0 = Release|Any CPU + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Release|x64.ActiveCfg = Release|Any CPU + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Release|x64.Build.0 = Release|Any CPU + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Release|x86.ActiveCfg = Release|Any CPU + {223BC484-DA6F-4217-8C19-E6A08095E13F}.Release|x86.Build.0 = Release|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Debug|x64.ActiveCfg = Debug|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Debug|x64.Build.0 = Debug|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Debug|x86.ActiveCfg = Debug|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Debug|x86.Build.0 = Debug|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Release|Any CPU.Build.0 = Release|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Release|x64.ActiveCfg = Release|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Release|x64.Build.0 = Release|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Release|x86.ActiveCfg = Release|Any CPU + {1302261A-5854-4084-85F6-2C82BDBCC50A}.Release|x86.Build.0 = Release|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Debug|x64.ActiveCfg = Debug|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Debug|x64.Build.0 = Debug|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Debug|x86.ActiveCfg = Debug|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Debug|x86.Build.0 = Debug|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Release|Any CPU.Build.0 = Release|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Release|x64.ActiveCfg = Release|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Release|x64.Build.0 = Release|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Release|x86.ActiveCfg = Release|Any CPU + {5B0661DF-83A1-457F-A6E3-D70E4128048A}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/aspnetcore-webapi/GrpcVsRest/README.md b/aspnetcore-webapi/GrpcVsRest/README.md new file mode 100644 index 000000000..f9fa34678 --- /dev/null +++ b/aspnetcore-webapi/GrpcVsRest/README.md @@ -0,0 +1,22 @@ +# gRPC vs REST in ASP.NET Core + +One ASP.NET Core app (`GrpcVsRest.Server`) serves the same product catalog as a minimal API, as a gRPC service, as JSON through gRPC JSON transcoding, and as gRPC-Web. `GrpcVsRest.Benchmarks` calls it every way and measures it. + +Build and test from this folder: + +``` +dotnet build GrpcVsRest.sln -c Release +dotnet test GrpcVsRest.sln -c Release --no-build +``` + +Run the measurements (each command starts its own copy of the server on ports 5100 and 5101, so stop any other server on those ports first): + +``` +dotnet run -c Release --project GrpcVsRest.Benchmarks -- demo +dotnet run -c Release --project GrpcVsRest.Benchmarks -- sizes +dotnet run -c Release --project GrpcVsRest.Benchmarks -- wire +dotnet run -c Release --project GrpcVsRest.Benchmarks -- --filter "*SerializationBenchmarks*" +dotnet run -c Release --project GrpcVsRest.Benchmarks -- --filter "*CallBenchmarks*" +``` + +The timings belong to the machine they ran on. Run them on your own hardware, ideally with nothing else running, and compare the ways of calling with each other rather than with our numbers.