dapr / dotnet-sdk

Dapr SDK for .NET
Apache License 2.0
1.12k stars 341 forks source link

Proposal: Strongly-typed non-remoted Actor clients #1158

Closed philliphoff closed 9 months ago

philliphoff commented 1 year ago

Overview

The Dapr .NET SDK offers two means to invoke methods of hosted actor instances, via a strongly-typed "remoted" proxy* or via an untyped "non-remoted" proxy. There are advantages and disadvantages to both approaches.

Remoted proxy:

Non-remoted proxy:

Neither approach may be an exact fit for developer's needs.

Proposal

I propose the .NET SDK offer strongly-typed non-remoted actor clients, using .NET source generators to generate strongly-typed actor interface implementations built upon the existing non-remoted ActorProxy proxy.

This approach would enable the following

Non-goals

Design

Actors Generators Package

The source generator(s) would be implemented and distributed in a new NuGet package, Dapr.Actors.Generators. This NuGet package would be added as a reference to client projects but using the OutputItemType=Analyzer.

Actor Client Generation

Suppose the client/host defines the actor interface:

namespace SampleActor;

public record SampleState(string Value);

public interface ISampleActor : IActor
{
  Task<SampleState> GetStateAsync();

  Task SetStateAsync(SampleState state);
}

The current remoted proxy invocation pattern would be:

var client = ActorProxy.Create<ISampleActor>("123", "ActorName");

var state = await client.GetStateAsync();

await client.SetStateAsync(new SampleState("Hello, World!"));

The current non-remoted proxy invocation pattern would be:

var client = ActorProxy.Create("123", "ActorName");

var state = await client.InvokeMethodAsync<SampleState>("GetStateAsync");

await client.InvokeMethodAsync("SetStateAsync", new SampleState("Hello, World!"));

As mentioned above, both of these approaches have a number of caveats.

Instead, the developer could indicate the desire to generate an actor client using a completely independent interface definition from that of the hosted actor:

using Dapr.Actors.Generators;

namespace ClientActor;

internal record ClientState(string Value);

[GenerateActorClient]
internal interface IClientActor
{
  Task<ClientState> GetStateAsync(CancellationToken cancellationToken = default);

  [ActorMethod(Name = "SetStateAsync")]
  Task SetClientStateAsync(ClientState state);
}

Items to note:

The generated client would be:

namespace ClientActor
{
  internal sealed class ClientActorClient : ClientActor.IClientActor
  {
    private readonly Dapr.Actors.Client.ActorProxy actorProxy;

    public ClientActorClient(Dapr.Actors.Client.ActorProxy actorProxy)
    {
        this.actorProxy = actorProxy;
    }

    public Task<ClientState> GetStateAsync(CancellationToken cancellationToken = default)
    {
        return this.actorProxy.InvokeMethodAsync<ClientState>("GetStateAsync", cancellationToken);
    }

    public Task SetClientStateAsync(ClientState state)
    {
        return this.actorProxy.InvokeMethodAsync("SetStateAsync", state);
    }
  }
}

Generated Client Use

The new proxy invocation pattern would be:

var proxy = ActorProxy.Create("123", "ActorName");

var client = new ClientActorClient(proxy);

var state = await client.GetStateAsync();

 await client.SetPrivateStateAsync(new ClientState("Hello, World!"));

Notes

halspang commented 1 year ago

@philliphoff - Thanks for writing up this proposal! Sorry it took me a bit to get to it, I saw the PR but somehow missed the proposal associated with it.

Overall, I think this is a good idea. My main concern is as follows:

Does this require that we make a separate interface for the Client? Or is there a way we can still have this based off the actual implementation and the code generation looks back at the interface and sees the annotations that way? My main concern here is that the two interfaces may get out of sync, which would lead to invocation problems.

Though I do see the benefits here in regards to a generated client but for an actor that exists in a different language, so ideally we'd have both.

philliphoff commented 1 year ago

@halspang

Does this require that we make a separate interface for the Client? Or is there a way we can still have this based off the actual implementation and the code generation looks back at the interface and sees the annotations that way? My main concern here is that the two interfaces may get out of sync, which would lead to invocation problems.

It doesn't require separate interfaces; you could still inherit from IActor and implement the service side using the same interface, but you might lose some of the benefits such as freedom to alter the names or support for record types, at least until what I'd call "phase 2" is implemented. The next step is to do something very similar for the remoted server side, where a skeleton actor service is generated with routes generated to map from endpoints to the appropriate method. This would be similar to the mapping currently being done, but hopefully a bit "lighter". It seemed best to worry about the server side as a separate issue/PR as this proposal can stand alone and the server side is more complex.

halspang commented 12 months ago

@philliphoff - Thanks for the clarifications! I think this is good to continue as is, looking forward to seeing what we can make out of it :)

theperm commented 2 months ago

Would it not make sense to also generate strongly types service invocation clients? Ive saw someone use refit but I think the drawback of this is uses an http client and might not use grpc if thats enabled for app protocol

philliphoff commented 2 months ago

Would it not make sense to also generate strongly types service invocation clients? Ive saw someone use refit but I think the drawback of this is uses an http client and might not use grpc if thats enabled for app protocol

@theperm If I understand you correctly, you suggest users define an interface that represents all (or some subset) of method invocation supported by an application, then using that interface to generate wrappers around the existing Dapr .NET SDK method invocation method? That's an interesting idea. You might even extend that idea to input/output bindings, too. Those would both be great things to have proposals/contributions for.

theperm commented 1 month ago

Exactly. Here is someone doing it using Refit but with the HTTP client interface. Refit with Dapr A similar concept would source generate a concrete class that would encapsulate the dapr client calls for service invocation. Our devs are hand rolling clients that do this anyway to make service invocation easier.

WhitWaldo commented 1 month ago

I have an aspect generator that creates this for service invocation for web backends. I'll have to dig into how easily it's open sourced (and made more generically available).