dotnet-cqrs/docs/event-streaming/grpc-streaming/grpc-clients.md

12 KiB

gRPC Streaming Clients

Build gRPC clients for event streaming in multiple languages.

Overview

gRPC event streaming clients support multiple programming languages:

  • C# / .NET - Native gRPC support via Grpc.Net.Client
  • TypeScript / Node.js - @grpc/grpc-js package
  • Go - google.golang.org/grpc
  • Python - grpcio package

C# / .NET Client

Installation

dotnet add package Grpc.Net.Client
dotnet add package Google.Protobuf
dotnet add package Grpc.Tools

Basic Client

using Grpc.Net.Client;
using Svrnty.CQRS.Events.Grpc;

// Create channel
using var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new EventStreamService.EventStreamServiceClient(channel);

// Subscribe to persistent stream
using var call = client.SubscribeToPersistent();

await call.RequestStream.WriteAsync(new PersistentSubscriptionRequest
{
    StreamName = "orders",
    StartOffset = 0,
    SubscriptionId = Guid.NewGuid().ToString()
});

await foreach (var @event in call.ResponseStream.ReadAllAsync())
{
    Console.WriteLine($"{@event.EventType}: {@event.EventId}");
}

Production Client

public class EventStreamGrpcClient : IDisposable
{
    private readonly GrpcChannel _channel;
    private readonly EventStreamService.EventStreamServiceClient _client;

    public EventStreamGrpcClient(string address)
    {
        _channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions
        {
            MaxReceiveMessageSize = 10 * 1024 * 1024,  // 10 MB
            MaxSendMessageSize = 10 * 1024 * 1024,
            Credentials = ChannelCredentials.SecureSsl
        });

        _client = new EventStreamService.EventStreamServiceClient(_channel);
    }

    public async Task SubscribeAsync(
        string streamName,
        long startOffset,
        Func<StreamEventProto, Task> handler,
        CancellationToken ct)
    {
        using var call = _client.SubscribeToPersistent(cancellationToken: ct);

        await call.RequestStream.WriteAsync(new PersistentSubscriptionRequest
        {
            StreamName = streamName,
            StartOffset = startOffset,
            SubscriptionId = Guid.NewGuid().ToString()
        });

        await foreach (var @event in call.ResponseStream.ReadAllAsync(ct))
        {
            await handler(@event);
        }
    }

    public void Dispose()
    {
        _channel?.Dispose();
    }
}

// Usage
using var client = new EventStreamGrpcClient("https://event-store.example.com");

await client.SubscribeAsync(
    "orders",
    startOffset: 0,
    handler: async @event =>
    {
        Console.WriteLine($"Received: {@event.EventType}");
        await ProcessEventAsync(@event);
    },
    ct);

TypeScript / Node.js Client

Installation

npm install @grpc/grpc-js @grpc/proto-loader
npm install --save-dev @types/node

Proto Loading

import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import path from 'path';

// Load proto file
const PROTO_PATH = path.join(__dirname, '../protos/event_stream.proto');
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
    keepCase: true,
    longs: String,
    enums: String,
    defaults: true,
    oneofs: true
});

const eventStreamProto = grpc.loadPackageDefinition(packageDefinition).svrnty.cqrs.events as any;

// Create client
const client = new eventStreamProto.EventStreamService(
    'localhost:5001',
    grpc.credentials.createInsecure()
);

Subscription Client

interface StreamEvent {
    event_id: string;
    event_type: string;
    stream_name: string;
    offset: number;
    timestamp: { seconds: number; nanos: number };
    data: string;
    metadata: Record<string, string>;
}

async function subscribe(
    streamName: string,
    startOffset: number,
    handler: (event: StreamEvent) => Promise<void>
): Promise<void> {
    const call = client.subscribeToPersistent();

    // Send subscription request
    call.write({
        stream_name: streamName,
        start_offset: startOffset,
        subscription_id: crypto.randomUUID()
    });

    // Receive events
    call.on('data', async (event: StreamEvent) => {
        try {
            await handler(event);
        } catch (error) {
            console.error('Error processing event:', error);
        }
    });

    call.on('error', (error: Error) => {
        console.error('Stream error:', error);
    });

    call.on('end', () => {
        console.log('Stream ended');
    });

    // Keep call alive
    return new Promise((resolve, reject) => {
        call.on('error', reject);
        call.on('end', resolve);
    });
}

// Usage
await subscribe('orders', 0, async (event) => {
    console.log(`${event.event_type}: ${event.event_id}`);

    const data = JSON.parse(event.data);
    await processEvent(data);
});

Go Client

Installation

go get google.golang.org/grpc
go get google.golang.org/protobuf/proto

Generate Code

protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    event_stream.proto

Subscription Client

package main

import (
    "context"
    "fmt"
    "io"
    "log"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    pb "github.com/your-org/event-stream/proto"
)

type EventHandler func(*pb.StreamEventProto) error

func Subscribe(
    address string,
    streamName string,
    startOffset int64,
    handler EventHandler,
) error {
    // Connect
    conn, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        return fmt.Errorf("failed to connect: %w", err)
    }
    defer conn.Close()

    client := pb.NewEventStreamServiceClient(conn)
    ctx := context.Background()

    // Create stream
    stream, err := client.SubscribeToPersistent(ctx)
    if err != nil {
        return fmt.Errorf("failed to subscribe: %w", err)
    }

    // Send subscription request
    err = stream.Send(&pb.PersistentSubscriptionRequest{
        StreamName:     streamName,
        StartOffset:    startOffset,
        SubscriptionId: uuid.New().String(),
    })
    if err != nil {
        return fmt.Errorf("failed to send request: %w", err)
    }

    // Receive events
    for {
        event, err := stream.Recv()
        if err == io.EOF {
            break
        }
        if err != nil {
            return fmt.Errorf("failed to receive: %w", err)
        }

        if err := handler(event); err != nil {
            log.Printf("Error processing event %s: %v", event.EventId, err)
        }
    }

    return nil
}

// Usage
func main() {
    err := Subscribe(
        "localhost:5001",
        "orders",
        0,
        func(event *pb.StreamEventProto) error {
            fmt.Printf("%s: %s\n", event.EventType, event.EventId)
            return processEvent(event)
        },
    )

    if err != nil {
        log.Fatal(err)
    }
}

Python Client

Installation

pip install grpcio grpcio-tools

Generate Code

python -m grpc_tools.protoc \
    -I. \
    --python_out=. \
    --grpc_python_out=. \
    event_stream.proto

Subscription Client

import grpc
import uuid
from event_stream_pb2 import PersistentSubscriptionRequest
from event_stream_pb2_grpc import EventStreamServiceStub

class EventStreamClient:
    def __init__(self, address: str):
        self.channel = grpc.insecure_channel(address)
        self.client = EventStreamServiceStub(self.channel)

    def subscribe(
        self,
        stream_name: str,
        start_offset: int,
        handler
    ):
        def request_iterator():
            yield PersistentSubscriptionRequest(
                stream_name=stream_name,
                start_offset=start_offset,
                subscription_id=str(uuid.uuid4())
            )

        responses = self.client.SubscribeToPersistent(request_iterator())

        for event in responses:
            try:
                handler(event)
            except Exception as e:
                print(f"Error processing event {event.event_id}: {e}")

    def close(self):
        self.channel.close()

# Usage
client = EventStreamClient('localhost:5001')

try:
    client.subscribe(
        'orders',
        0,
        lambda event: print(f"{event.event_type}: {event.event_id}")
    )
finally:
    client.close()

Async Client

import asyncio
import grpc.aio
from event_stream_pb2 import PersistentSubscriptionRequest
from event_stream_pb2_grpc import EventStreamServiceStub

class AsyncEventStreamClient:
    def __init__(self, address: str):
        self.channel = grpc.aio.insecure_channel(address)
        self.client = EventStreamServiceStub(self.channel)

    async def subscribe(
        self,
        stream_name: str,
        start_offset: int,
        handler
    ):
        async def request_iterator():
            yield PersistentSubscriptionRequest(
                stream_name=stream_name,
                start_offset=start_offset,
                subscription_id=str(uuid.uuid4())
            )

        call = self.client.SubscribeToPersistent(request_iterator())

        async for event in call:
            try:
                await handler(event)
            except Exception as e:
                print(f"Error processing event {event.event_id}: {e}")

    async def close(self):
        await self.channel.close()

# Usage
async def main():
    client = AsyncEventStreamClient('localhost:5001')

    try:
        await client.subscribe(
            'orders',
            0,
            lambda event: print(f"{event.event_type}: {event.event_id}")
        )
    finally:
        await client.close()

asyncio.run(main())

Authentication

C# with Bearer Token

var credentials = CallCredentials.FromInterceptor((context, metadata) =>
{
    metadata.Add("Authorization", $"Bearer {accessToken}");
    return Task.CompletedTask;
});

var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
    Credentials = ChannelCredentials.Create(
        new SslCredentials(),
        credentials)
});

TypeScript with Metadata

const metadata = new grpc.Metadata();
metadata.add('authorization', `Bearer ${accessToken}`);

const call = client.subscribeToPersistent(metadata);

Go with Interceptor

func authInterceptor(token string) grpc.UnaryClientInterceptor {
    return func(
        ctx context.Context,
        method string,
        req, reply interface{},
        cc *grpc.ClientConn,
        invoker grpc.UnaryInvoker,
        opts ...grpc.CallOption,
    ) error {
        ctx = metadata.AppendToOutgoingContext(ctx, "authorization", fmt.Sprintf("Bearer %s", token))
        return invoker(ctx, method, req, reply, cc, opts...)
    }
}

conn, err := grpc.Dial(
    address,
    grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
    grpc.WithUnaryInterceptor(authInterceptor(token)),
)

Python with Credentials

call_credentials = grpc.access_token_call_credentials(access_token)
channel_credentials = grpc.ssl_channel_credentials()
composite_credentials = grpc.composite_channel_credentials(
    channel_credentials,
    call_credentials
)

channel = grpc.secure_channel('localhost:5001', composite_credentials)

Best Practices

DO

  • Use secure channels in production (TLS)
  • Implement reconnection logic
  • Handle errors gracefully
  • Use async/await patterns
  • Close channels properly
  • Use appropriate timeouts
  • Implement authentication
  • Log connection lifecycle

DON'T

  • Don't use insecure channels in production
  • Don't ignore connection errors
  • Don't block event processing
  • Don't leak resources (unclosed channels)
  • Don't use very short timeouts
  • Don't skip authentication
  • Don't ignore cancellation
  • Don't forget error handling

See Also