Skip to content

Commit

Permalink
Dependency injection hosting extension (#92)
Browse files Browse the repository at this point in the history
  • Loading branch information
cretz committed Jun 28, 2023
1 parent ebaf1af commit 01946ba
Show file tree
Hide file tree
Showing 17 changed files with 968 additions and 48 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ jobs:
path: |
src/Temporalio/bin/Release/*.nupkg
src/Temporalio/bin/Release/*.snupkg
src/Temporalio.Extensions.Hosting/bin/Release/*.nupkg
src/Temporalio.Extensions.Hosting/bin/Release/*.snupkg
src/Temporalio.Extensions.OpenTelemetry/bin/Release/*.nupkg
src/Temporalio.Extensions.OpenTelemetry/bin/Release/*.snupkg
Expand Down
49 changes: 12 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ present.
- [Workflow Replay](#workflow-replay)
- [Activities](#activities)
- [Activity Definition](#activity-definition)
- [Activity Dependency Injection](#activity-dependency-injection)
- [Activity Execution Context](#activity-execution-context)
- [Activity Heartbeating and Cancellation](#activity-heartbeating-and-cancellation)
- [Activity Worker Shutdown](#activity-worker-shutdown)
Expand Down Expand Up @@ -295,6 +296,9 @@ var host = Host.CreateDefaultBuilder(args)

This can be wrapped in a client "provider" or other similar async task wrapper if needed.

To use worker services or activities with dependency injection, see the
[Temporalio.Extensions.Hosting](src/Temporalio.Extensions.Hosting/) project.

#### Data Conversion

Data converters are used to convert raw Temporal payloads to/from actual .NET types. A custom data converter can be set
Expand Down Expand Up @@ -377,43 +381,8 @@ Notes about the above code:

#### Worker as Generic Host

It is not a coincidence that one of the overloads of `ExecuteAsync` has the same signature as
`Microsoft.Extensions.Hosting.BackgroundService.ExecuteAsync`. So to implement `BackgroundService`, you can do:

```csharp
using Temporalio.Client;
using Temporalio.Worker;

public sealed class MyWorker : BackgroundService
{
private readonly ILoggerFactory loggerFactory;

public Worker(ILoggerFactory loggerFactory) => this.loggerFactory = loggerFactory;

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var worker = new TemporalWorker(
await TemporalClient.ConnectAsync(new()
{
TargetHost = "localhost:7233",
LoggerFactory = loggerFactory,
}),
new TemporalWorkerOptions("my-task-queue").
AddActivity(MyActivities.MyActivity).
AddWorkflow<MyWorkflow>());
await worker.ExecuteAsync(stoppingToken);
}
}
```

Then you can configure it like:

```csharp
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices(svcs => svcs.AddHostedService<MyWorker>())
.Build();
host.Run();
```
See the [Temporalio.Extensions.Hosting](src/Temporalio.Extensions.Hosting/) project for support for worker services and
activity dependency injection.

### Workflows

Expand Down Expand Up @@ -1031,6 +1000,12 @@ Notes about activity definitions:
The call must accept a single parameter of `Temporalio.Converters.IRawValue[]` for the arguments. Only one dynamic
activity may be registered on a worker.

#### Activity Dependency Injection

To have activity classes instantiated via a DI container to support dependency injection, see the
[Temporalio.Extensions.Hosting](src/Temporalio.Extensions.Hosting/) project which supports worker services in addition
to activity dependency injection.

#### Activity Execution Context

During activity execution, an async-local activity context is available via `ActivityExecutionContext.Current`. This
Expand Down
7 changes: 7 additions & 0 deletions Temporalio.sln
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Tests", "tests\T
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.OpenTelemetry", "src\Temporalio.Extensions.OpenTelemetry\Temporalio.Extensions.OpenTelemetry.csproj", "{D4AC2E2B-1C24-491D-9175-874D448D30FE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.Hosting", "src\Temporalio.Extensions.Hosting\Temporalio.Extensions.Hosting.csproj", "{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -34,10 +36,15 @@ Global
{D4AC2E2B-1C24-491D-9175-874D448D30FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D4AC2E2B-1C24-491D-9175-874D448D30FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D4AC2E2B-1C24-491D-9175-874D448D30FE}.Release|Any CPU.Build.0 = Release|Any CPU
{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{7AE1422A-0937-40D7-9A62-431DD0E2F6D5} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{D5F245E2-73A2-49C6-8C52-FBE892E87169} = {F2683DAA-F157-448E-96C8-DF7BB019886D}
{D4AC2E2B-1C24-491D-9175-874D448D30FE} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
EndGlobalSection
EndGlobal
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.Extensions.DependencyInjection;

namespace Temporalio.Extensions.Hosting
{
/// <summary>
/// Interface for configuring <see cref="TemporalWorkerServiceOptions" /> for
/// <see cref="TemporalWorkerService" />. Methods for using this are as extensions in this
/// namespace.
/// </summary>
public interface ITemporalWorkerServiceOptionsBuilder
{
/// <summary>
/// Gets the task queue for this worker service.
/// </summary>
string TaskQueue { get; }

/// <summary>
/// Gets the service collection being configured.
/// </summary>
IServiceCollection Services { get; }
}
}
93 changes: 93 additions & 0 deletions src/Temporalio.Extensions.Hosting/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Hosting and Dependency Injection Support

This extension adds support for worker
[Generic Host](https://learn.microsoft.com/en-us/dotnet/core/extensions/generic-host)s and activity
[Dependency Injection](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection) to the
[Temporal .NET SDK](https://github.com/temporalio/sdk-dotnet).

⚠️ UNDER ACTIVE DEVELOPMENT

This SDK is under active development and has not released a stable version yet. APIs may change in incompatible ways
until the SDK is marked stable.

## Quick Start

Add the `Temporalio.Extensions.Hosting` package from
[NuGet](https://www.nuget.org/packages/Temporalio.Extensions.Hosting). For example, using the `dotnet` CLI:

dotnet add package Temporalio.Extensions.Hosting --prerelease

To create a worker service, you can do the following:

```csharp
using Temporalio.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);
// Add a hosted Temporal worker which returns a builder to add activities and workflows
builder.Services.
AddHostedTemporalWorker(
"my-temporal-host:7233",
"my-namespace",
"my-task-queue").
AddScopedActivities<MyActivityClass>().
AddWorkflow<MyWorkflow>();

// Run
var host = builder.Build();
host.Run();
```

This creates a hosted Temporal worker which returns a builder. Then `MyActivityClass` is added to the service collection
as scoped (via
[`TryAddScoped`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.extensions.servicecollectiondescriptorextensions.tryaddscoped))
and registered on the worker. Also `MyWorkflow` is registered as a workflow on the worker.

This means that for every activity invocation, a new scope is created and the the activity is obtained via the service
provider. So if it is registered as scoped the activity is created each time but if it registered as singleton it is
created only once and reused. The activity's constructor can be used to accept injected dependencies.

Workflows are inherently self-contained, deterministic units of work and therefore should never call anything external.
Therefore, there is no such thing as dependency injection for workflows, their construction and lifetime is managed by
Temporal.

## Worker Services

When this extension is depended upon, two overloads for `AddHostedTemporalWorker` are added as extension methods on
`IServiceCollection` in the `Microsoft.Extensions.DependencyInjection`.

One overload of `AddHostedTemporalWorker`, used in the quick start sample above, accepts the client target host, the
client namespace, and the worker task queue. This form will connect to a client for the worker. The other overload of
`AddHostedTemporalWorker` only accepts the worker task queue. In the latter, an `ITemporalClient` can either be set on
the services container and therefore reused across workers, or the resulting builder can have client options set.

When called, these register a `TemporalWorkerServiceOptions` options class with the container and return a
`ITemporalWorkerServiceOptionsBuilder` for configuring the worker service options.

For adding activity classes to the service collection and registering activities with the worker, the following
extension methods exist on the builder each accepting activity type:

* `AddSingletonActivities` - `TryAddSingleton` + register activities on worker
* `AddScopedActivities` - `TryAddScoped` + register activities on worker
* `AddTransientActivities` - `TryAddTransient` + register activities on worker
* `AddStaticActivities` - register activities on worker that all must be static
* `AddActivitiesInstance` - register activities on worker via an existing instance

These all expect the activity methods to have the `[Activity]` attribute on them. If an activity type is already added
to the service collection, `ApplyTemporalActivities` can be used to just register the activities on the worker.

For registering workflows on the worker, `AddWorkflow` extension method is available. This does nothing to the service
collection because the construction and lifecycle of workflows is managed by Temporal. Dependency injection for
workflows is intentionally not supported.

Other worker and client options can be configured on the builder via the `ConfigureOptions` extension method. With no
parameters, this returns an `OptionsBuilder<TemporalWorkerServiceOptions>` to use. When provided an action, the options
are available as parameters that can be configured. `TemporalWorkerServiceOptions` simply extends
`TemporalWorkerOptions` with an added property for optional client options that can be set to connect a client on worker
start instead of expecting a `ITemporalClient` to be present on the service collection.

## Activity Dependency Injection without Worker Services

Some users may prefer to manually create the `TemporalWorker` without using host support, but still make their
activities created via the service provider. `CreateTemporalActivityDefinitions` extension methods are present on
`IServiceProvider` that will return a collection of `ActivityDefinition` instances for each activity on the type. These
can be added to the `TemporalWorkerOptions` directly.
113 changes: 113 additions & 0 deletions src/Temporalio.Extensions.Hosting/ServiceProviderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Temporalio.Activities;

namespace Temporalio.Extensions.Hosting
{
/// <summary>
/// Temporal extension methods for <see cref="IServiceProvider" />.
/// </summary>
public static class ServiceProviderExtensions
{
/// <summary>
/// Create activity definitions for every activity-attributed method on the given type. For
/// non-static methods, this will use the service provider to get the instance to call the
/// method on.
/// </summary>
/// <typeparam name="T">Type to create activity definitions from.</typeparam>
/// <param name="provider">Service provider for creating the instance for non-static
/// activities.</param>
/// <returns>Collection of activity definitions.</returns>
public static IReadOnlyCollection<ActivityDefinition> CreateTemporalActivityDefinitions<T>(
this IServiceProvider provider) =>
provider.CreateTemporalActivityDefinitions(typeof(T));

/// <summary>
/// Create activity definitions for every activity-attributed method on the given type. For
/// non-static methods, this will use the service provider to get the instance to call the
/// method on.
/// </summary>
/// <param name="provider">Service provider for creating the instance for non-static
/// activities.</param>
/// <param name="type">Type to create activity definitions from.</param>
/// <returns>Collection of activity definitions.</returns>
public static IReadOnlyCollection<ActivityDefinition> CreateTemporalActivityDefinitions(
this IServiceProvider provider, Type type) =>
type.
GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance).
Where(method => method.IsDefined(typeof(ActivityAttribute))).
Select(method => provider.CreateTemporalActivityDefinition(type, method)).
ToList();

/// <summary>
/// Create activity definition for the given activity-attributed method on the given
/// instance type. If the method is non-static, this will use the service provider to get
/// the instance to call the method on.
/// </summary>
/// <param name="provider">Service provider for creating the instance if the method is
/// non-static.</param>
/// <param name="instanceType">Type of the instance.</param>
/// <param name="method">Method to create activity definition from.</param>
/// <returns>Created definition.</returns>
public static ActivityDefinition CreateTemporalActivityDefinition(
this IServiceProvider provider, Type instanceType, MethodInfo method)
{
// Invoker can be async (i.e. returns Task<object?>)
Func<object?[], Task<object>> invoker = async args =>
{
// If static, just invoke and unwrap exception
if (method.IsStatic)
{
try
{
return method.Invoke(null, args);
}
catch (TargetInvocationException e)
{
ExceptionDispatchInfo.Capture(e.InnerException!).Throw();
// Unreachable
throw new InvalidOperationException("Unreachable");
}
}
// Wrap in a scope
using (var scope = provider.CreateScope())
{
object? result;
try
{
result = method.Invoke(scope.ServiceProvider.GetRequiredService(instanceType), args);
}
catch (TargetInvocationException e)
{
ExceptionDispatchInfo.Capture(e.InnerException!).Throw();
// Unreachable
throw new InvalidOperationException("Unreachable");
}
// In order to make sure the scope lasts the life of the activity, we need to
// wait on the task if it's a task
if (result is Task resultTask)
{
await resultTask.ConfigureAwait(false);
// We have to use reflection to extract value if it's a Task<>
var resultTaskType = resultTask.GetType();
if (resultTaskType.IsGenericType)
{
result = resultTaskType.GetProperty("Result")!.GetValue(resultTask);
}
else
{
result = ValueTuple.Create();
}
}
return result;
}
};
return ActivityDefinition.Create(method, invoker);
}
}
}
Loading

0 comments on commit 01946ba

Please sign in to comment.