Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
philliphoff opened this issue Oct 6, 2023 · 3 comments · Fixed by #1165
Closed

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

philliphoff opened this issue Oct 6, 2023 · 3 comments · Fixed by #1165
Assignees
Labels
area/actor kind/enhancement New feature or request
Milestone

Comments

@philliphoff
Copy link
Contributor

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:

  • Requires actor be hosted via the Dapr .NET SDK
  • Allows multiple arguments be defined on actor interface methods
  • Requires actor interfaces be public
  • Requires (client) actor interfaces be inherited from IActor
  • Requires (client) actor interfaces exactly match that of the host (e.g. naming, etc.)
  • Does not (currently) support JSON serialization of all types (e.g. record types)
  • Tends to enforce .NET naming conventions for hosted actor methods, even when used by non-.NET clients (e.g. GetStateAsync())

Non-remoted proxy:

  • Supports cross-platform hosted actors
  • Allows only a single argument be passed during method invocations
  • Supports broader JSON serialization of types (e.g. record types)
  • Loose typing introduces risk method mis-matches between actor client and host

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

  • A developer may want the benefits of a strongly-typed interface but want to use modern types or JSON serialization methods
  • A developer may want decoupled client/host actor interfaces in order to match differences in name and styling conventions between each
  • A developer may want strongly-typed ".NET native" client interfaces but allow hosted actors to offer endpoints suitable across multiple platforms
  • A developer may want clients to use proper await/async patterns even if the hosted actor interface does not
  • A developer may want not want to expose or share the strongly-typed actor interface used by clients
  • A developer may want to define a strongly-typed client interface for a non-.NET-hosted actor

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

  • Client interfaces could be decoupled from host interfaces (i.e. need not be shared definitions)
  • Client interfaces could be internal (as source generators operate "inside" the client assembly)
  • Client interfaces can differ in naming (by using attributes to indicate the "real" name of its corresponding hosted actor method)
  • Client interfaces can take advantage of the non-remoted proxy's JSON serialization capabilities but in a strongly-typed manner

Non-goals

  • Support for multiple arguments per method invocation

    While source generators could be used to offer similar multi-argument serialization, given the ease with which values can be bundled together by the client, I believe there is little need for this capability and it is a capability that could be added later upon sufficient demand.

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 client indicates generation of the actor client implementation using the GenerateActorClientAttribute
  • The client actor interface can be internal
  • The client actor interface need not inherit from IActor
  • The client can rename interface methods, mapping them to the host actor method name using the ActorMethodAttribute
  • The client can use async/await patterns (e.g. acceptingCancellationToken) even if the host actor interface does not

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
Copy link
Contributor

halspang commented Dec 1, 2023

@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.

@halspang halspang added kind/enhancement New feature or request area/actor labels Dec 1, 2023
@philliphoff
Copy link
Contributor Author

philliphoff commented Dec 1, 2023

@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
Copy link
Contributor

halspang commented Dec 5, 2023

@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 :)

@halspang halspang added this to the v1.13 milestone Dec 5, 2023
@philliphoff philliphoff self-assigned this Feb 16, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/actor kind/enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants