Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ProductDto?> RestHttp1() => _clients.RestHttp1.GetFromJsonAsync<ProductDto>("/api/products/42");

[Benchmark, BenchmarkCategory("1 product")]
public Task<ProductDto?> RestHttp2() => _clients.RestHttp2.GetFromJsonAsync<ProductDto>("/api/products/42");

[Benchmark, BenchmarkCategory("1 product")]
public Task<ProductDto?> TranscodedJson() => _clients.Transcoded.GetFromJsonAsync<ProductDto>("/v1/products/42");

[Benchmark, BenchmarkCategory("1 product")]
public async Task<Product> Grpc() => await _clients.Grpc.GetProductAsync(new GetProductRequest { Id = 42 });

[Benchmark, BenchmarkCategory("1 product")]
public async Task<Product> GrpcWeb() => await _clients.GrpcWeb.GetProductAsync(new GetProductRequest { Id = 42 });

// 100 products
[Benchmark(Baseline = true), BenchmarkCategory("100 products")]
public Task<List<ProductDto>?> ListRestHttp1() =>
_clients.RestHttp1.GetFromJsonAsync<List<ProductDto>>("/api/products");

[Benchmark, BenchmarkCategory("100 products")]
public Task<List<ProductDto>?> ListRestHttp1Gzip() =>
_clients.RestHttp1Gzip.GetFromJsonAsync<List<ProductDto>>("/api/products");

[Benchmark, BenchmarkCategory("100 products")]
public Task<List<ProductDto>?> ListRestHttp2() =>
_clients.RestHttp2.GetFromJsonAsync<List<ProductDto>>("/api/products");

[Benchmark, BenchmarkCategory("100 products")]
public Task<TranscodedList?> ListTranscodedJson() =>
_clients.Transcoded.GetFromJsonAsync<TranscodedList>("/v1/products");

[Benchmark, BenchmarkCategory("100 products")]
public async Task<ProductList> ListGrpc() => await _clients.Grpc.ListProductsAsync(new ListProductsRequest());

[Benchmark, BenchmarkCategory("100 products")]
public async Task<ProductList> 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<Task> call) =>
Task.WhenAll(Enumerable.Range(0, 100).Select(_ => call()));
}
Original file line number Diff line number Diff line change
@@ -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<string, ByteCounter?>? 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;
}
}
44 changes: 44 additions & 0 deletions aspnetcore-webapi/GrpcVsRest/GrpcVsRest.Benchmarks/CatalogHost.cs
Original file line number Diff line number Diff line change
@@ -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<WebApplication> StartAsync(
Action<KestrelServerOptions>? 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var read = await inner.ReadAsync(buffer, cancellationToken);
counter.AddReceived(read);
return read;
}

public override Task<int> 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<byte> 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);
}
}
Loading
Loading