Runly jobs support the dependency injection (DI) software design pattern, which is a technique for achieving Inversion of Control (IoC) between classes and their dependencies.
There are two ways for dependencies to be injected into a Runly job: via the constructor or via the ProcessAsync
method.
Constructor Injection
A constructor dependency is simply added to the constructor parameters so that when the job is instantiated the dependencies are provided. These dependencies can be saved as local variables to be used in InitializeAsync
and FinalizeAsync
. For example, if InitializeAsync
needs an IDbConnection
to execute a database query, IDbConnection
should be added to the constructor. Unless these dependencies are thread-safe, they should generally not be used in ProcessAsync
. The second method of dependency injection should be used for dependencies that ProcessAsync
requires.
Example
This example job takes two dependencies in the constructor: IDatabase
and ILogger<MyJob>
. It then uses those dependencies in InitializeAsync
and GetItemsAsync
.
public class MyJob : Job<MyConfig, string>
{
readonly IDatabase db;
readonly ILogger<MyJob> logger;
public MyJob(MyConfig cfg, IDatabase db, ILogger<MyJob> logger)
: base(cfg)
{
this.db = db;
this.logger = logger;
}
public override async Task InitializeAsync()
{
logger.LogDebug("Doing something in the database");
await db.DoSomething();
}
public override IAsyncEnumerable<string> GetItemsAsync()
{
return db.GetSomeItemsFromTheDatabase().ToAsyncEnumerable();
}
}
Method Injection
The ProcessAsync
method will be called in parallel unless Options.CanProcessInParallel == false
or Config.Execution.ParallelTaskCount == 1
. Because of this, dependencies used in ProcessAsync
that are not thread-safe, such as a database connection, should be taken through the ProcessAsync
method and not the constructor. A dependency taken through the constructor would be shared across multiple parallel executions of ProcessAsync
, likely leading to hard to diagnose errors. When ProcessAsync
is called, each dependency is retrieved from a scoped dependency injection container. In this case, the scope is one of many Task
s which is calling ProcessAsync
. As a best practice, ProcessAsync
should only use dependencies passed in via the method itself.
ProcessAsync
can accept between zero to sixteen dependencies in addition to TItem
, depending on which Job
base class is extended. In addition to Job<TConfig, TItem>
there is Job<TConfig, TItem, T1>
, Job<TConfig, TItem, T1, T2>
and so on, up to Job<TConfig, TItem, T1, T2,…T16>
. These extra generic type parameters correspond to the ProcessAsync
definition in the class. For Job<TConfig, TItem, T1>
, ProcessAsync
is defined as ProcessAsync(TItem item, T1 arg1)
. This allows strongly typed dependencies to be added to ProcessAsync
simply by adding generic type parameters to the base class being extended.
Example
Building on the previous example, ProcessAsync
takes its own dependency on IDatabase
so that it will get a new instance of IDatabase
for each parallel task execution.
public class MyJob : Job<MyConfig, string, IDatabase>
{
readonly IDatabase db;
readonly ILogger<MyJob> logger;
public MyJob(MyConfig cfg, IDatabase db, ILogger<MyJob> logger)
: base(cfg)
{
this.db = db;
this.logger = logger;
}
public override async Task InitializeAsync()
{
logger.LogDebug("Doing something in the database");
await db.DoSomething();
}
public override IAsyncEnumerable<string> GetItemsAsync()
{
return await db.GetSomeItemsFromTheDatabase().ToAsyncEnumerable();
}
public override async Task<Result> ProcessAsync(string item, IDatabase db)
{
// do some super intensive processing of some kind
// this is the method's instance of db
await db.SaveThing(result);
// ILogger is thread-safe, so we can use the class variable here
logger.LogDebug("Saved the thing");
return Result.Success();
}
}
Registering Dependencies
Runly jobs use the built-in service container, IServiceProvider
, by default.
A common pattern for registering dependencies in your app is to create an extension method to register dependencies for a job or group of jobs:
public static class ServiceExtensions
{
public static IServiceCollection AddMyJobDependencies(this IServiceCollection services)
{
return services.AddScoped<IDatabase, MyDatabase>();
}
}
class Program
{
static async Task Main(string[] args)
{
await JobHost.CreateDefaultBuilder(args)
.ConfigureServices((host, services) =>
{
services.AddMyJobDependencies();
})
.Build()
.RunJobAsync();
}
}
This example registers the IDatabase
dependency that MyJob
needs. Note that the jobs within the application (e.g. MyJob
) and config types (e.g. MyConfig
) are automatically registered for you.
Using Job Configuration to Register Dependencies
A real database implementation would require a connection string to a database. In the case of MyJob
, we can amend MyConfig
to include a connection string:
public class MyConfig : Config
{
public string ConnectionString { get; set; }
}
Then, when registering MyDatabase
, we can reference the config to create an instance of MyDatabase
:
services.AddScoped<IDatabase>(s =>
{
var config = s.GetRequiredService<MyConfig>();
return new MyDatabase(config.ConnectionString);
});
This pattern of using the config when registering dependencies is common enough that Runly provides some convenient helper extension methods to streamline this process. The following example is equivalent to above:
services.AddScoped<IDatabase, MyConfig>((s, config) =>
new MyDatabase(config.ConnectionString)
);
To make this work, the JobHost
default builder auto-registers the config passed-in to the job app as a singleton. It is the config for the job that is about to execute and can be resolved using the concrete type, MyConfig
or the base Config
type.
Lifetime Considerations for Parallel Tasks
As mentioned previously, since the ProcessAsync
method can be called in parallel, you should take non-thread-safe dependencies in the ProcessAsync
method rather than the constructor. However, in order for this to fully work, you have to make sure to register your non-thread-safe dependencies using the correct lifetime scope. The built-in IServiceProvider
includes three lifetime scopes:
Transient
Transient lifetime services (AddTransient
) are created each time they’re requested from the service container. This lifetime works best for lightweight, stateless services. Every call to ProcessAsync
with one of these services will use a different instance.
Scoped
Scoped lifetime services (AddScoped
) are created once per parallel task. This is the scope you should use if you have a heavier, non-thread-safe dependency in ProcessAsync
.
Singleton
Singleton lifetime services (AddSingleton
) are created the first time they’re requested and return the same instance each time they’re requested from the service container. You shouldn’t register a dependency as a singleton if it is to be used in ProcessAsync
. Every call to ProcessAsync
with one of these services will use the same instance. In that case, you can just take the dependency in the constructor and share it across calls to ProcessAsync
.