commit 179d4a8dc4fe28c8a6d406e6edd43dd913c0dd0c Author: Tijmen van Nesselrooij Date: Wed Sep 25 19:27:20 2024 +0200 Add gRPC server example with file download diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbbd0b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ \ No newline at end of file diff --git a/TestGrpc.Test/GlobalUsings.cs b/TestGrpc.Test/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/TestGrpc.Test/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/TestGrpc.Test/TestGrpc.Test.csproj b/TestGrpc.Test/TestGrpc.Test.csproj new file mode 100644 index 0000000..98e1abb --- /dev/null +++ b/TestGrpc.Test/TestGrpc.Test.csproj @@ -0,0 +1,37 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + \ No newline at end of file diff --git a/TestGrpc.Test/UnitTest1.cs b/TestGrpc.Test/UnitTest1.cs new file mode 100644 index 0000000..c4fce7e --- /dev/null +++ b/TestGrpc.Test/UnitTest1.cs @@ -0,0 +1,233 @@ +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using Xunit.Abstractions; + +namespace TestGrpc.Test; + +public class UnitTest1 : IntegrationTestBase +{ + public UnitTest1(GrpcTestFixture fixture, ITestOutputHelper outputHelper) + : base(fixture, outputHelper) + { + } + + [Fact] + public async Task Test1() + { + // Arrange + var client = new Greeter.GreeterClient(Channel); + + // Act + using var response = client.DownloadFile(new FileRequest { FilePath = "Files/test.txt" }); + using var memoryStream = new MemoryStream(); + + var receivedFileName = string.Empty; + await foreach (var chunk in response.ResponseStream.ReadAllAsync()) + { + if (!string.IsNullOrEmpty(chunk.FileName)) + { + receivedFileName = chunk.FileName; + } + + memoryStream.Write(chunk.Chunk.Span); + } + + memoryStream.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(memoryStream); + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync(); + Console.WriteLine(line); + } + + // Assert + Assert.Equal("test.txt", receivedFileName); + } +} + +internal class ForwardingLoggerProvider : ILoggerProvider +{ + private readonly LogMessage _logAction; + + public ForwardingLoggerProvider(LogMessage logAction) + { + _logAction = logAction; + } + + public ILogger CreateLogger(string categoryName) + { + return new ForwardingLogger(categoryName, _logAction); + } + + public void Dispose() + { + } + + internal class ForwardingLogger : ILogger + { + private readonly string _categoryName; + private readonly LogMessage _logAction; + + public ForwardingLogger(string categoryName, LogMessage logAction) + { + _categoryName = categoryName; + _logAction = logAction; + } + + public IDisposable BeginScope(TState state) + { + return null!; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + _logAction(logLevel, _categoryName, eventId, formatter(state, exception), exception); + } + } +} + +internal class GrpcTestContext : IDisposable where TStartup : class +{ + private readonly Stopwatch _stopwatch; + private readonly GrpcTestFixture _fixture; + private readonly ITestOutputHelper _outputHelper; + + public GrpcTestContext(GrpcTestFixture fixture, ITestOutputHelper outputHelper) + { + _stopwatch = Stopwatch.StartNew(); + _fixture = fixture; + _outputHelper = outputHelper; + _fixture.LoggedMessage += WriteMessage; + } + + private void WriteMessage(LogLevel logLevel, string category, EventId eventId, string message, Exception? exception) + { + var log = $"{_stopwatch.Elapsed.TotalSeconds:N3}s {category} - {logLevel}: {message}"; + if (exception != null) + { + log += Environment.NewLine + exception.ToString(); + } + _outputHelper.WriteLine(log); + } + + public void Dispose() + { + _fixture.LoggedMessage -= WriteMessage; + } +} + +public delegate void LogMessage(LogLevel logLevel, string categoryName, EventId eventId, string message, Exception? exception); + +public class GrpcTestFixture : IDisposable where TStartup : class +{ + private TestServer? _server; + private IHost? _host; + private HttpMessageHandler? _handler; + private Action? _configureWebHost; + + public event LogMessage? LoggedMessage; + + public GrpcTestFixture() + { + LoggerFactory = new LoggerFactory(); + LoggerFactory.AddProvider(new ForwardingLoggerProvider((logLevel, category, eventId, message, exception) => + { + LoggedMessage?.Invoke(logLevel, category, eventId, message, exception); + })); + } + + public void ConfigureWebHost(Action configure) + { + _configureWebHost = configure; + } + + private void EnsureServer() + { + if (_host == null) + { + var builder = new HostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(LoggerFactory); + }) + .ConfigureWebHostDefaults(webHost => + { + webHost + .UseTestServer() + .UseStartup(); + + _configureWebHost?.Invoke(webHost); + }); + _host = builder.Start(); + _server = _host.GetTestServer(); + _handler = _server.CreateHandler(); + } + } + + public LoggerFactory LoggerFactory { get; } + + public HttpMessageHandler Handler + { + get + { + EnsureServer(); + return _handler!; + } + } + + public void Dispose() + { + _handler?.Dispose(); + _host?.Dispose(); + _server?.Dispose(); + } + + public IDisposable GetTestContext(ITestOutputHelper outputHelper) + { + return new GrpcTestContext(this, outputHelper); + } +} + +public class IntegrationTestBase : IClassFixture>, IDisposable +{ + private GrpcChannel? _channel; + private IDisposable? _testContext; + + protected GrpcTestFixture Fixture { get; set; } + + protected ILoggerFactory LoggerFactory => Fixture.LoggerFactory; + + protected GrpcChannel Channel => _channel ??= CreateChannel(); + + protected GrpcChannel CreateChannel() + { + return GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions + { + LoggerFactory = LoggerFactory, + HttpHandler = Fixture.Handler + }); + } + + public IntegrationTestBase(GrpcTestFixture fixture, ITestOutputHelper outputHelper) + { + Fixture = fixture; + _testContext = Fixture.GetTestContext(outputHelper); + } + + public void Dispose() + { + _testContext?.Dispose(); + _channel = null; + } +} \ No newline at end of file diff --git a/TestGrpc/Files/test.txt b/TestGrpc/Files/test.txt new file mode 100644 index 0000000..f71d9db --- /dev/null +++ b/TestGrpc/Files/test.txt @@ -0,0 +1,2 @@ +Hello world +This is a test \ No newline at end of file diff --git a/TestGrpc/Program.cs b/TestGrpc/Program.cs new file mode 100644 index 0000000..20e06e9 --- /dev/null +++ b/TestGrpc/Program.cs @@ -0,0 +1,16 @@ +namespace TestGrpc; + +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.UseStartup(); + }); +} \ No newline at end of file diff --git a/TestGrpc/Properties/launchSettings.json b/TestGrpc/Properties/launchSettings.json new file mode 100644 index 0000000..037afcf --- /dev/null +++ b/TestGrpc/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5210", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7004;http://localhost:5210", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/TestGrpc/Protos/greet.proto b/TestGrpc/Protos/greet.proto new file mode 100644 index 0000000..bb53cab --- /dev/null +++ b/TestGrpc/Protos/greet.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option csharp_namespace = "TestGrpc"; + +package greet; + +service Greeter { + rpc DownloadFile (FileRequest) returns (stream ChunkMsg); +} + +message FileRequest { + string FilePath = 1; +} + +message ChunkMsg { + string FileName = 1; + int64 FileSize = 2; + bytes Chunk = 3; +} diff --git a/TestGrpc/Services/GreeterService.cs b/TestGrpc/Services/GreeterService.cs new file mode 100644 index 0000000..f367947 --- /dev/null +++ b/TestGrpc/Services/GreeterService.cs @@ -0,0 +1,49 @@ +using Google.Protobuf; +using Grpc.Core; +using TestGrpc; + +namespace TestGrpc.Services; + +public class GreeterService : Greeter.GreeterBase +{ + private readonly ILogger _logger; + public GreeterService(ILogger logger) + { + _logger = logger; + } + + public override async Task DownloadFile(FileRequest request, IServerStreamWriter responseStream, ServerCallContext context) + { + string filePath = request.FilePath; + + if (!File.Exists(filePath)) + { + return; + } + + var fileInfo = new FileInfo(filePath); + + var chunk = new ChunkMsg + { + FileName = Path.GetFileName(filePath), + FileSize = fileInfo.Length + }; + + int fileChunkSize = 64 * 1024; + + byte[] fileByteArray = File.ReadAllBytes(filePath); + byte[] fileChunk = new byte[fileChunkSize]; + int fileOffset = 0; + + while (fileOffset < fileByteArray.Length && !context.CancellationToken.IsCancellationRequested) + { + int length = Math.Min(fileChunkSize, fileByteArray.Length - fileOffset); + Buffer.BlockCopy(fileByteArray, fileOffset, fileChunk, 0, length); + fileOffset += length; + ByteString byteString = ByteString.CopyFrom(fileChunk); + + chunk.Chunk = byteString; + await responseStream.WriteAsync(chunk).ConfigureAwait(false); + } + } +} diff --git a/TestGrpc/Startup.cs b/TestGrpc/Startup.cs new file mode 100644 index 0000000..895c223 --- /dev/null +++ b/TestGrpc/Startup.cs @@ -0,0 +1,26 @@ +using TestGrpc.Services; + +namespace TestGrpc; + +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddGrpc(o => o.EnableDetailedErrors = true); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapGrpcService(); + }); + } +} \ No newline at end of file diff --git a/TestGrpc/TestGrpc.csproj b/TestGrpc/TestGrpc.csproj new file mode 100644 index 0000000..3b25b6d --- /dev/null +++ b/TestGrpc/TestGrpc.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TestGrpc/appsettings.Development.json b/TestGrpc/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/TestGrpc/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/TestGrpc/appsettings.json b/TestGrpc/appsettings.json new file mode 100644 index 0000000..1aef507 --- /dev/null +++ b/TestGrpc/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + } +} diff --git a/temp.sln b/temp.sln new file mode 100644 index 0000000..7519bed --- /dev/null +++ b/temp.sln @@ -0,0 +1,28 @@ + +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}") = "TestGrpc", "TestGrpc\TestGrpc.csproj", "{D742F0F4-75F3-4FD8-8F61-3ED0396ACA1E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestGrpc.Test", "TestGrpc.Test\TestGrpc.Test.csproj", "{222D86CD-BBE1-4297-8FD8-0805EEC5B676}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D742F0F4-75F3-4FD8-8F61-3ED0396ACA1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D742F0F4-75F3-4FD8-8F61-3ED0396ACA1E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D742F0F4-75F3-4FD8-8F61-3ED0396ACA1E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D742F0F4-75F3-4FD8-8F61-3ED0396ACA1E}.Release|Any CPU.Build.0 = Release|Any CPU + {222D86CD-BBE1-4297-8FD8-0805EEC5B676}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {222D86CD-BBE1-4297-8FD8-0805EEC5B676}.Debug|Any CPU.Build.0 = Debug|Any CPU + {222D86CD-BBE1-4297-8FD8-0805EEC5B676}.Release|Any CPU.ActiveCfg = Release|Any CPU + {222D86CD-BBE1-4297-8FD8-0805EEC5B676}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal