467 lines
11 KiB
Markdown
467 lines
11 KiB
Markdown
# Source Generators
|
|
|
|
How automatic gRPC service implementation generation works.
|
|
|
|
## Overview
|
|
|
|
`Svrnty.CQRS.Grpc.Generators` uses Roslyn source generators to automatically create gRPC service implementations at compile time. This eliminates boilerplate code and ensures type safety between .proto definitions and C# handlers.
|
|
|
|
**Benefits:**
|
|
- ✅ **Zero boilerplate** - No manual service implementation
|
|
- ✅ **Compile-time safety** - Errors caught during build
|
|
- ✅ **Type checking** - Ensures proto and C# types match
|
|
- ✅ **Automatic updates** - Regenerates when proto changes
|
|
- ✅ **IDE support** - IntelliSense for generated code
|
|
|
|
## How It Works
|
|
|
|
```
|
|
┌──────────────────────────────────┐
|
|
│ Build Process │
|
|
├──────────────────────────────────┤
|
|
│ 1. Compile .proto files │
|
|
│ 2. Generate C# types (Grpc.Tools)│
|
|
│ 3. Source generator runs │
|
|
│ - Reads proto definitions │
|
|
│ - Discovers CQRS handlers │
|
|
│ - Generates service impls │
|
|
│ 4. Compile generated code │
|
|
│ 5. Build completes │
|
|
└──────────────────────────────────┘
|
|
```
|
|
|
|
## Generated Code
|
|
|
|
### CommandServiceImpl
|
|
|
|
**From .proto:**
|
|
```protobuf
|
|
service CommandService {
|
|
rpc CreateUser (CreateUserCommand) returns (CreateUserResponse);
|
|
rpc DeleteUser (DeleteUserCommand) returns (google.protobuf.Empty);
|
|
}
|
|
```
|
|
|
|
**Generated C# (simplified):**
|
|
```csharp
|
|
public class CommandServiceImpl : CommandService.CommandServiceBase
|
|
{
|
|
private readonly IServiceProvider _serviceProvider;
|
|
|
|
public CommandServiceImpl(IServiceProvider serviceProvider)
|
|
{
|
|
_serviceProvider = serviceProvider;
|
|
}
|
|
|
|
public override async Task<CreateUserResponse> CreateUser(
|
|
CreateUserCommand request,
|
|
ServerCallContext context)
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
|
|
// Get validator
|
|
var validator = scope.ServiceProvider
|
|
.GetService<IValidator<CreateUserCommand>>();
|
|
|
|
// Validate
|
|
if (validator != null)
|
|
{
|
|
var validationResult = await validator.ValidateAsync(
|
|
request,
|
|
context.CancellationToken);
|
|
|
|
if (!validationResult.IsValid)
|
|
{
|
|
throw CreateValidationException(validationResult);
|
|
}
|
|
}
|
|
|
|
// Get handler
|
|
var handler = scope.ServiceProvider
|
|
.GetRequiredService<ICommandHandler<CreateUserCommand, int>>();
|
|
|
|
// Execute
|
|
var userId = await handler.HandleAsync(request, context.CancellationToken);
|
|
|
|
// Return response
|
|
return new CreateUserResponse { UserId = userId };
|
|
}
|
|
|
|
public override async Task<Empty> DeleteUser(
|
|
DeleteUserCommand request,
|
|
ServerCallContext context)
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
|
|
var handler = scope.ServiceProvider
|
|
.GetRequiredService<ICommandHandler<DeleteUserCommand>>();
|
|
|
|
await handler.HandleAsync(request, context.CancellationToken);
|
|
|
|
return new Empty();
|
|
}
|
|
|
|
private RpcException CreateValidationException(ValidationResult validationResult)
|
|
{
|
|
var badRequest = new BadRequest();
|
|
|
|
foreach (var error in validationResult.Errors)
|
|
{
|
|
badRequest.FieldViolations.Add(new FieldViolation
|
|
{
|
|
Field = ToCamelCase(error.PropertyName),
|
|
Description = error.ErrorMessage
|
|
});
|
|
}
|
|
|
|
var status = new Google.Rpc.Status
|
|
{
|
|
Code = (int)Code.InvalidArgument,
|
|
Message = "Validation failed",
|
|
Details = { Any.Pack(badRequest) }
|
|
};
|
|
|
|
return status.ToRpcException();
|
|
}
|
|
}
|
|
```
|
|
|
|
### QueryServiceImpl
|
|
|
|
**From .proto:**
|
|
```protobuf
|
|
service QueryService {
|
|
rpc GetUser (GetUserQuery) returns (UserDto);
|
|
}
|
|
```
|
|
|
|
**Generated C# (simplified):**
|
|
```csharp
|
|
public class QueryServiceImpl : QueryService.QueryServiceBase
|
|
{
|
|
private readonly IServiceProvider _serviceProvider;
|
|
|
|
public QueryServiceImpl(IServiceProvider serviceProvider)
|
|
{
|
|
_serviceProvider = serviceProvider;
|
|
}
|
|
|
|
public override async Task<UserDto> GetUser(
|
|
GetUserQuery request,
|
|
ServerCallContext context)
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
|
|
var handler = scope.ServiceProvider
|
|
.GetRequiredService<IQueryHandler<GetUserQuery, UserDto>>();
|
|
|
|
var result = await handler.HandleAsync(request, context.CancellationToken);
|
|
|
|
return result;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Type Mapping
|
|
|
|
### Proto to C# Mapping
|
|
|
|
The source generator maps .proto types to C# CQRS types:
|
|
|
|
| Proto Message | C# Type | Handler Type |
|
|
|---------------|---------|--------------|
|
|
| `CreateUserCommand` | `CreateUserCommand` | `ICommandHandler<CreateUserCommand, TResult>` |
|
|
| `DeleteUserCommand` | `DeleteUserCommand` | `ICommandHandler<DeleteUserCommand>` |
|
|
| `GetUserQuery` | `GetUserQuery` | `IQueryHandler<GetUserQuery, UserDto>` |
|
|
| `UserDto` | `UserDto` | Return type |
|
|
|
|
### Response Type Detection
|
|
|
|
**Command with result:**
|
|
```protobuf
|
|
rpc CreateUser (CreateUserCommand) returns (CreateUserResponse);
|
|
```
|
|
Generator looks for: `ICommandHandler<CreateUserCommand, TResult>`
|
|
|
|
**Command without result:**
|
|
```protobuf
|
|
rpc DeleteUser (DeleteUserCommand) returns (google.protobuf.Empty);
|
|
```
|
|
Generator looks for: `ICommandHandler<DeleteUserCommand>`
|
|
|
|
## Build Integration
|
|
|
|
### Project Configuration
|
|
|
|
**.csproj:**
|
|
```xml
|
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
<PropertyGroup>
|
|
<TargetFramework>net10.0</TargetFramework>
|
|
</PropertyGroup>
|
|
|
|
<ItemGroup>
|
|
<!-- Include .proto files -->
|
|
<Protobuf Include="Protos\**\*.proto" GrpcServices="Server" />
|
|
</ItemGroup>
|
|
|
|
<ItemGroup>
|
|
<!-- Source generator package -->
|
|
<PackageReference Include="Svrnty.CQRS.Grpc.Generators" Version="1.0.0" />
|
|
|
|
<!-- gRPC packages -->
|
|
<PackageReference Include="Grpc.AspNetCore" Version="2.68.0" />
|
|
<PackageReference Include="Grpc.Tools" Version="2.76.0">
|
|
<PrivateAssets>all</PrivateAssets>
|
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
|
</PackageReference>
|
|
</ItemGroup>
|
|
</Project>
|
|
```
|
|
|
|
### Build Output
|
|
|
|
During build, you'll see:
|
|
```
|
|
Restoring NuGet packages...
|
|
Generating C# from .proto files...
|
|
Running source generators...
|
|
- Svrnty.CQRS.Grpc.Generators
|
|
Generated: CommandServiceImpl.g.cs
|
|
Generated: QueryServiceImpl.g.cs
|
|
Compiling...
|
|
Build succeeded.
|
|
```
|
|
|
|
## Viewing Generated Code
|
|
|
|
### In Visual Studio
|
|
|
|
1. Expand project in Solution Explorer
|
|
2. Expand "Dependencies" → "Analyzers" → "Svrnty.CQRS.Grpc.Generators"
|
|
3. View generated files
|
|
|
|
### In Rider
|
|
|
|
1. Navigate to a service usage
|
|
2. Right-click → "Go to Declaration"
|
|
3. View generated implementation
|
|
|
|
### Output Directory
|
|
|
|
Generated files are written to:
|
|
```
|
|
obj/Debug/net10.0/generated/Svrnty.CQRS.Grpc.Generators/
|
|
```
|
|
|
|
### Manual Inspection
|
|
|
|
```bash
|
|
# View generated files
|
|
cat obj/Debug/net10.0/generated/Svrnty.CQRS.Grpc.Generators/*.cs
|
|
```
|
|
|
|
## Customization
|
|
|
|
### Disabling Generation
|
|
|
|
Temporarily disable the generator:
|
|
|
|
**.csproj:**
|
|
```xml
|
|
<ItemGroup>
|
|
<PackageReference Include="Svrnty.CQRS.Grpc.Generators" Version="1.0.0">
|
|
<!-- Disable analyzer/generator -->
|
|
<PrivateAssets>all</PrivateAssets>
|
|
<ExcludeAssets>analyzers</ExcludeAssets>
|
|
</PackageReference>
|
|
</ItemGroup>
|
|
```
|
|
|
|
### Excluding Specific Services
|
|
|
|
Use `[GrpcIgnore]` attribute:
|
|
|
|
```csharp
|
|
[GrpcIgnore]
|
|
public record InternalCommand
|
|
{
|
|
public int Id { get; init; }
|
|
}
|
|
```
|
|
|
|
No gRPC RPC will be generated for this command.
|
|
|
|
## Error Handling
|
|
|
|
### Common Build Errors
|
|
|
|
#### Error: Handler not found
|
|
|
|
**Message:**
|
|
```
|
|
Could not find ICommandHandler<CreateUserCommand, int>
|
|
```
|
|
|
|
**Cause:** Handler not registered in DI
|
|
|
|
**Solution:**
|
|
```csharp
|
|
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
|
|
```
|
|
|
|
#### Error: Type mismatch
|
|
|
|
**Message:**
|
|
```
|
|
CreateUserCommand in .proto does not match C# type
|
|
```
|
|
|
|
**Cause:** Property names don't match
|
|
|
|
**Proto:**
|
|
```protobuf
|
|
message CreateUserCommand {
|
|
string user_name = 1; // snake_case
|
|
}
|
|
```
|
|
|
|
**C#:**
|
|
```csharp
|
|
public record CreateUserCommand
|
|
{
|
|
public string UserName { get; init; } // PascalCase - OK
|
|
}
|
|
```
|
|
|
|
Properties match (case-insensitive) - this should work.
|
|
|
|
**Proto:**
|
|
```protobuf
|
|
message CreateUserCommand {
|
|
string name = 1;
|
|
}
|
|
```
|
|
|
|
**C#:**
|
|
```csharp
|
|
public record CreateUserCommand
|
|
{
|
|
public string FullName { get; init; } // Different name - ERROR
|
|
}
|
|
```
|
|
|
|
**Solution:** Ensure property names match (case-insensitive).
|
|
|
|
#### Error: Circular dependency
|
|
|
|
**Message:**
|
|
```
|
|
Circular reference detected in message definitions
|
|
```
|
|
|
|
**Cause:** Proto messages reference each other
|
|
|
|
**Solution:** Break circular reference using separate DTOs.
|
|
|
|
## Performance
|
|
|
|
### Compile-Time Generation
|
|
|
|
- **Zero runtime overhead** - Code generated at build time
|
|
- **No reflection** - Direct method calls
|
|
- **Optimized** - IL identical to hand-written code
|
|
|
|
### Incremental Build
|
|
|
|
Source generators support incremental builds:
|
|
- Only regenerate when .proto files change
|
|
- Fast rebuilds when only C# code changes
|
|
|
|
## Troubleshooting
|
|
|
|
### Generator Not Running
|
|
|
|
**Symptoms:** No service implementations generated
|
|
|
|
**Checks:**
|
|
1. Verify package installed:
|
|
```xml
|
|
<PackageReference Include="Svrnty.CQRS.Grpc.Generators" Version="1.0.0" />
|
|
```
|
|
|
|
2. Clean and rebuild:
|
|
```bash
|
|
dotnet clean
|
|
dotnet build
|
|
```
|
|
|
|
3. Check build output for generator messages
|
|
|
|
### Generated Code Not Visible
|
|
|
|
**Symptoms:** Can't find generated classes in IDE
|
|
|
|
**Solutions:**
|
|
1. Close and reopen solution
|
|
2. Rebuild project
|
|
3. Restart IDE
|
|
4. Check obj/Debug/net10.0/generated/ directory
|
|
|
|
### Build Warnings
|
|
|
|
**Warning:** "Generator produced no output"
|
|
|
|
**Cause:** No matching handlers found
|
|
|
|
**Solution:** Ensure handlers are registered before build.
|
|
|
|
## Best Practices
|
|
|
|
### ✅ DO
|
|
|
|
- Keep .proto files in Protos/ directory
|
|
- Use GrpcServices="Server" in .csproj
|
|
- Register all handlers in DI
|
|
- Clean build after .proto changes
|
|
- Review generated code occasionally
|
|
|
|
### ❌ DON'T
|
|
|
|
- Don't modify generated files (they'll be overwritten)
|
|
- Don't commit generated files to source control
|
|
- Don't disable generators without reason
|
|
- Don't ignore build warnings
|
|
|
|
## Advanced Scenarios
|
|
|
|
### Multiple Proto Files
|
|
|
|
```xml
|
|
<ItemGroup>
|
|
<Protobuf Include="Protos\commands.proto" GrpcServices="Server" />
|
|
<Protobuf Include="Protos\queries.proto" GrpcServices="Server" />
|
|
<Protobuf Include="Protos\common.proto" GrpcServices="None" />
|
|
</ItemGroup>
|
|
```
|
|
|
|
### Shared Proto Files
|
|
|
|
```xml
|
|
<ItemGroup>
|
|
<!-- Reference shared proto from another project -->
|
|
<Protobuf Include="..\Shared.Contracts\Protos\*.proto"
|
|
GrpcServices="Client"
|
|
Link="Protos\Shared\%(Filename)%(Extension)" />
|
|
</ItemGroup>
|
|
```
|
|
|
|
## See Also
|
|
|
|
- [gRPC Integration Overview](README.md)
|
|
- [Getting Started](getting-started-grpc.md)
|
|
- [Proto File Setup](proto-file-setup.md)
|
|
- [Service Implementation](service-implementation.md)
|
|
- [gRPC Troubleshooting](grpc-troubleshooting.md)
|
|
- [Roslyn Source Generators](https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/source-generators-overview)
|