Skip to content

Commit

Permalink
Added description of JobWorker to README
Browse files Browse the repository at this point in the history
  • Loading branch information
KristofferStrube committed Jun 20, 2024
1 parent e7019f5 commit 9f975da
Show file tree
Hide file tree
Showing 7 changed files with 123 additions and 39 deletions.
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ The sample project can be demoed at https://kristofferstrube.github.io/Blazor.We

On each page, you can find the corresponding code for the example in the top right corner.

# Approach
# Getting Started

Many others like [Tewr/BlazorWorker](https://github.com/Tewr/BlazorWorker) and [LostBeard/SpawnDev.BlazorJS](https://github.com/LostBeard/SpawnDev.BlazorJS) have made libraries like this before. This project differs a bit from the other projects by utilizing [the wasm-experimental workload](https://devblogs.microsoft.com/dotnet/use-net-7-from-any-javascript-app-in-net-7/). This simplifies the code needed for this to work a lot. The catch to this is that you will need to have the code for your workers in another project. For me this is not only a negative as it also makes it very clear that they do not share memory and that they run in separate contexts, similar to how the *Blazor WASM* project is separate in a *Blazor WebApp*.

So to get started you really only need to *create a new console project* and then make a few adjustments to the `.csproj`. In the end it should look something like this:
Expand All @@ -39,6 +40,8 @@ So to get started you really only need to *create a new console project* and the
```
And then you can do whatever you want in the `Program.cs` file, but I've added some helpers that make it easier to communicate with the main window and create objects.

## SlimWorker

Here I have an example of the code needed for a simple pong worker that broadcasts when it is ready to listen for a ping, responds with a pong when it receives that, and then shuts down.

```csharp
Expand Down Expand Up @@ -112,6 +115,48 @@ This looks like so:

![ping pong demo](./docs/ping-pong.gif?raw=true)

## JobWorker

Another more basic abstraction is the `JobWorker`. This simple abstraction runs some job with an input and an output on a worker. The `.csproj` look identical to the one used for the `SlimWorker`.

But what differs is that we need to create a class that implements the interface `Job<TInput, TOutput>` in the worker project. A simple way to do this is by extending the abstract class `JsonJob` which uses JSON as the format for transfering its input and output. This limits us to only use inputs and outputs that can be JSON serialized and deserialized.

Here were implement a job that can find the sum of the codes of each individual char in a string.

```csharp
public class StringSumJob : JsonJob<string, int>
{
public override int Work(string input)
{
int result = 0;
for (int i = 0; i < input.Length; i++)
{
result += input[i];
}
return result;
}
}
```

Then we need to replace the content of the `Program.cs` in the worker project with the following to instantiate the job.

```csharp
if (!OperatingSystem.IsBrowser())
throw new PlatformNotSupportedException("Can only be run in the browser!");

new StringSumJob().Execute(args);
```

Finally to call the worker from our main Blazor program we only need the following.

```csharp
var jobWorker = await JobWorker<string, int, StringSumJob>.CreateAsync(JSRuntime);

int result = await jobWorker.ExecuteAsync(input);
```

We can create the `JobWorker` a single time and then run it multiple times with different inputs. Doing this spares us from importing the needed WASM assemblies multiple times which can make consecutive runs much faster.


# Related repositories
The library uses the following other packages to support its features:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"profiles": {
"KristofferStrube.Blazor.WebWorkers.PongWorker": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:54479;http://localhost:54481"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"profiles": {
"KristofferStrube.Blazor.WebWorkers.StringSumWorker": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:54478;http://localhost:54480"
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace KristofferStrube.Blazor.WebWorkers.StringSumWorker;

public class StringSumJob : JSONJob<string, int>
public class StringSumJob : JsonJob<string, int>
{
public override int Work(string input)
{
Expand Down
8 changes: 8 additions & 0 deletions src/KristofferStrube.Blazor.WebWorkers/Job.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Collections.Concurrent;

namespace KristofferStrube.Blazor.WebWorkers;

public interface Job<TInput, TOutput>
{
public abstract static Task<TOutput> ExecuteAsync<TJob>(TInput input, Worker worker, ConcurrentDictionary<string, TaskCompletionSource<TOutput>> pendingTasks) where TJob : Job<TInput, TOutput>;
}
39 changes: 4 additions & 35 deletions src/KristofferStrube.Blazor.WebWorkers/JobWorker.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
using KristofferStrube.Blazor.DOM;
using KristofferStrube.Blazor.WebIDL;
using KristofferStrube.Blazor.WebIDL;
using KristofferStrube.Blazor.WebWorkers.Extensions;
using KristofferStrube.Blazor.Window;
using Microsoft.JSInterop;
using System.Collections.Concurrent;
using System.Text.Json;

namespace KristofferStrube.Blazor.WebWorkers;

public class JobWorker<TInput, TOutput, TJob> : Worker where TJob : JSONJob<TInput, TOutput>
public class JobWorker<TInput, TOutput, TJob> : Worker where TJob : Job<TInput, TOutput>
{
private readonly ConcurrentDictionary<string, TaskCompletionSource<TOutput>> pendingTasks = new();

/// <summary>
/// Creates a <see cref="JobWorker<TInput, TOutput, TJob>"/> that can execute some specific <see cref="TJob"/> on a worker thread.
/// Creates a <see cref="JobWorker{TInput, TOutput, TJob}"/> that can execute some specific <see cref="TJob"/> on a worker thread.
/// </summary>
/// <param name="jSRuntime">An <see cref="IJSRuntime"/> instance.</param>
/// <returns></returns>
public static new async Task<JobWorker<TInput, TOutput, TJob>> CreateAsync(IJSRuntime jSRuntime)
{
await using IJSObjectReference helper = await jSRuntime.GetHelperAsync();
Expand All @@ -33,33 +29,6 @@ protected JobWorker(IJSRuntime jSRuntime, IJSObjectReference jSReference, Creati

public async Task<TOutput> ExecuteAsync(TInput input)
{
string requestIdentifier = Guid.NewGuid().ToString();
var tcs = new TaskCompletionSource<TOutput>();
pendingTasks[requestIdentifier] = tcs;

EventListener<MessageEvent> eventListener = default!;
eventListener = await EventListener<MessageEvent>.CreateAsync(JSRuntime, async e =>
{
await RemoveOnMessageEventListenerAsync(eventListener);
await eventListener.DisposeAsync();

JSONJob<object, object>.JobResponse response = await e.GetDataAsync<JSONJob<object, object>.JobResponse>();
if (pendingTasks.Remove(response.RequestIdentifier, out TaskCompletionSource<TOutput> successTaskCompletionSource))
{
successTaskCompletionSource.SetResult(JsonSerializer.Deserialize<TOutput>(response.OutputSerialized)!);
}
});

await AddOnMessageEventListenerAsync(eventListener);

await PostMessageAsync(new JSONJob<object, object>.JobArguments()
{
Namespace = typeof(TJob).Assembly.GetName().Name!,
Type = typeof(TJob).Name,
RequestIdentifier = requestIdentifier,
InputSerialized = JsonSerializer.Serialize(input)
});

return await tcs.Task;
return await TJob.ExecuteAsync<TJob>(input, this, pendingTasks);
}
}
42 changes: 40 additions & 2 deletions src/KristofferStrube.Blazor.WebWorkers/JsonJob.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System.Runtime.Versioning;
using KristofferStrube.Blazor.DOM;
using KristofferStrube.Blazor.Window;
using System.Collections.Concurrent;
using System.Runtime.Versioning;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace KristofferStrube.Blazor.WebWorkers;

public abstract class JSONJob<TInput, TOutput>
public abstract class JsonJob<TInput, TOutput> : Job<TInput, TOutput>
{
/// <summary>
/// The actual work being done by the job. This will be run when the job is executed.
Expand All @@ -28,6 +31,41 @@ public TOutput ExecuteWithoutUsingWorker(TInput input)
return outputSerializedAndDeserialized;
}

/// <summary>
/// How an input is transfered to the <see cref="JobWorker{TInput, TOutput, TJob}"/> for the <see cref="JsonJob{TInput, TOutput}"/>.
/// </summary>
public static async Task<TOutput> ExecuteAsync<TJob>(TInput input, Worker worker, ConcurrentDictionary<string, TaskCompletionSource<TOutput>> pendingTasks) where TJob : Job<TInput, TOutput>
{
string requestIdentifier = Guid.NewGuid().ToString();
var tcs = new TaskCompletionSource<TOutput>();
pendingTasks[requestIdentifier] = tcs;

EventListener<MessageEvent> eventListener = default!;
eventListener = await EventListener<MessageEvent>.CreateAsync(worker.JSRuntime, async e =>
{
await worker.RemoveOnMessageEventListenerAsync(eventListener);
await eventListener.DisposeAsync();

JobResponse response = await e.GetDataAsync<JobResponse>();
if (pendingTasks.Remove(response.RequestIdentifier, out TaskCompletionSource<TOutput>? successTaskCompletionSource))
{
successTaskCompletionSource.SetResult(JsonSerializer.Deserialize<TOutput>(response.OutputSerialized)!);
}
});

await worker.AddOnMessageEventListenerAsync(eventListener);

await worker.PostMessageAsync(new JobArguments()
{
Namespace = typeof(TJob).Assembly.GetName().Name!,
Type = typeof(TJob).Name,
RequestIdentifier = requestIdentifier,
InputSerialized = JsonSerializer.Serialize(input)
});

return await tcs.Task;
}

/// <summary>
/// This method is called from the Worker project.
/// </summary>
Expand Down

0 comments on commit 9f975da

Please sign in to comment.