Skip to content

Commit

Permalink
Add reliability features and refactor internals (#4)
Browse files Browse the repository at this point in the history
* Add Reliability service

* Further updates

* Implement IDisposible within the Reliable host

* Add docs

* Use DI forwarding to strip out excess generics

* Typo

* Update samples

* Testing

* Use token to unblock on exit

* Minor naming changes

* Don't throw on cancel

* Update README.md

* Listen to console lifetime

* Prepare for release

* Update README.md

* Update description
  • Loading branch information
Hawxy authored Nov 10, 2018
1 parent 06bda01 commit 75af1b2
Show file tree
Hide file tree
Showing 12 changed files with 258 additions and 31 deletions.
6 changes: 3 additions & 3 deletions Discord.Addons.Hosting/Discord.Addons.Hosting.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<PackageId>Discord.Addons.Hosting</PackageId>
<Version>1.0.2</Version>
<Version>1.1.0</Version>
<Authors>Hawxy</Authors>
<Description>Discord.Net hosting with IHostedService and Microsoft.Extensions.Hosting</Description>
<Description>Simplifying Discord.Net hosting with .NET Generic Host (Microsoft.Extensions.Hosting)</Description>
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
<PackageLicenseUrl>https://github.com/Hawxy/Discord.Addons.Hosting/blob/azure-pipelines/LICENSE</PackageLicenseUrl>
<PackageLicenseUrl>https://github.com/Hawxy/Discord.Addons.Hosting/blob/master/LICENSE</PackageLicenseUrl>
<PackageProjectUrl>https://github.com/Hawxy/Discord.Addons.Hosting</PackageProjectUrl>
<RepositoryUrl>https://github.com/Hawxy/Discord.Addons.Hosting</RepositoryUrl>
<RepositoryType>git</RepositoryType>
Expand Down
8 changes: 4 additions & 4 deletions Discord.Addons.Hosting/DiscordClientHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ namespace Discord.Addons.Hosting
{
/// <summary>
/// Simple handler to manage client creation
/// <typeparam name="T">The type of Discord.Net client. Type must inherit from <see cref="BaseSocketClient"/></typeparam>
/// <typeparam name="T">The type of Discord.Net client. Type must be or inherit from <see cref="DiscordSocketClient"/></typeparam>
/// </summary>
public class DiscordClientHandler<T> where T: BaseSocketClient, new()
public class DiscordClientHandler<T> where T: DiscordSocketClient
{
private T _client;

Expand All @@ -45,11 +45,11 @@ public void AddDiscordClient(T client)
/// </summary>
/// <param name="config">The Discord client configuration object. Ensure the type is compatible with the client type <typeparamref name="T"/></param>
/// <exception cref="InvalidOperationException">Thrown if client initialized more than once</exception>
public void UseDiscordConfiguration<Y>(Y config) where Y : DiscordConfig
public void UseDiscordConfiguration<Y>(Y config) where Y : DiscordSocketConfig
{
if(_client != null)
throw new InvalidOperationException("Client can only be initialized once!");
//has performance issues, but does it matter if it's only called once?

_client = (T)Activator.CreateInstance(typeof(T), config);
}

Expand Down
11 changes: 6 additions & 5 deletions Discord.Addons.Hosting/DiscordHostBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ public static class DiscordHostBuilderExtensions
/// <remarks>
/// A <see cref="HostBuilderContext"/> is supplied so that the configuration and service provider can be used.
/// </remarks>
/// <typeparam name="T">The type of Discord.Net client. Type must inherit from <see cref="BaseSocketClient"/></typeparam>
/// <typeparam name="T">The type of Discord.Net client. Type must be or inherit from <see cref="DiscordSocketClient"/></typeparam>
/// <param name="builder">The host builder to configure.</param>
/// <param name="config">The delegate for configuring the <see cref="DiscordClientHandler{T}" /> that will be used to construct the discord client.</param>
/// <returns>The (generic) host builder.</returns>
/// <exception cref="ArgumentNullException">Thrown if <see cref="config"/> is null</exception>
/// <exception cref="InvalidOperationException">Thrown client is already added or logged in</exception>
public static IHostBuilder ConfigureDiscordClient<T>(this IHostBuilder builder, Action<HostBuilderContext, DiscordClientHandler<T>> config) where T: BaseSocketClient, new()
public static IHostBuilder ConfigureDiscordClient<T>(this IHostBuilder builder, Action<HostBuilderContext, DiscordClientHandler<T>> config) where T: DiscordSocketClient, new()
{
if(config == null)
throw new ArgumentNullException(nameof(config));
Expand All @@ -62,8 +62,10 @@ public static class DiscordHostBuilderExtensions
throw new InvalidOperationException("Client logged in before host startup! Make sure you aren't calling LoginAsync manually");

collection.AddSingleton(client);
if(typeof(T) != typeof(DiscordSocketClient))
collection.AddSingleton<DiscordSocketClient>(x => x.GetRequiredService<T>());
collection.AddSingleton<LogAdapter>();
collection.AddHostedService<DiscordHostedService<T>>();
collection.AddHostedService<DiscordHostedService>();
});

return builder;
Expand Down Expand Up @@ -109,8 +111,7 @@ public static IHostBuilder UseCommandService(this IHostBuilder builder, Action<H

var csc = new CommandServiceConfig();
config(context, csc);
var service = new CommandService(csc);
collection.AddSingleton(service);
collection.AddSingleton(new CommandService(csc));

});

Expand Down
18 changes: 12 additions & 6 deletions Discord.Addons.Hosting/DiscordHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ limitations under the License.

namespace Discord.Addons.Hosting
{
internal class DiscordHostedService<T> : IHostedService, IDisposable where T: BaseSocketClient, new()
internal class DiscordHostedService : IHostedService, IDisposable
{
private readonly ILogger<DiscordHostedService<T>> _logger;
private readonly T _client;
private readonly ILogger<DiscordHostedService> _logger;
private readonly DiscordSocketClient _client;
private readonly IConfiguration _config;

public DiscordHostedService(ILogger<DiscordHostedService<T>> logger, T client, IConfiguration config, IServiceProvider services)
public DiscordHostedService(ILogger<DiscordHostedService> logger, DiscordSocketClient client, IConfiguration config, IServiceProvider services)
{
_logger = logger;
_client = client;
Expand All @@ -42,12 +42,18 @@ public DiscordHostedService(ILogger<DiscordHostedService<T>> logger, T client, I
var adapter = services.GetRequiredService<LogAdapter>();
//workaround for correct logging category
adapter.UseLogger(logger);

//In cases where the constructor is called multiple times
client.Log -= adapter.Log;
client.Log += adapter.Log;
var cs = services.GetService<CommandService>();
if (cs != null)
{
cs.Log -= adapter.Log;
cs.Log += adapter.Log;
}

}

public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Discord.Net hosted service is starting");
Expand All @@ -64,7 +70,7 @@ public async Task StopAsync(CancellationToken cancellationToken)
public void Dispose()
{
_logger.LogInformation("Disposing Discord.Net hosted service");
_client.Dispose();
_client.Dispose();
}
}
}
2 changes: 1 addition & 1 deletion Discord.Addons.Hosting/LogAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@ private string DefaultFormatter(LogMessage message, Exception _)
=> $"{message.Source}: {message.Exception?.ToString() ?? message.Message}";

private static LogLevel GetLogLevel(LogSeverity severity)
=> (LogLevel) (Math.Abs((int) severity - 5));
=> (LogLevel) Math.Abs((int) severity - 5);
}
}
91 changes: 91 additions & 0 deletions Discord.Addons.Hosting/Reliability/ReliableDiscordHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Discord.WebSocket;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

// Based on ReliabilityService by Foxbot
namespace Discord.Addons.Hosting.Reliability
{
internal class ReliableDiscordHost : IDisposable
{
private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(30);

private readonly DiscordSocketClient _discord;
private readonly ILogger _logger;
private readonly IHost _host;
private CancellationTokenSource _cts;

public ReliableDiscordHost(DiscordSocketClient discord, ILogger logger, IHost host)
{
_cts = new CancellationTokenSource();
_discord = discord;
_logger = logger;
_host = host;
_logger.LogInformation("Using Discord.Net Reliability service - Host will attempt to restart after a 30 second disconnect");

_discord.Connected += ConnectedAsync;
_discord.Disconnected += DisconnectedAsync;
}

private Task ConnectedAsync()
{
_logger.LogDebug("Discord client reconnected, resetting cancel token...");
_cts.Cancel();
_cts = new CancellationTokenSource();
_logger.LogDebug("Discord client reconnected, cancel token reset.");

return Task.CompletedTask;
}

private Task DisconnectedAsync(Exception _e)
{
_logger.LogInformation("Discord client disconnected, starting timeout task...");
_ = Task.Delay(_timeout, _cts.Token).ContinueWith(async _ =>
{
_logger.LogDebug("Timeout expired, continuing to check client state...");
await CheckStateAsync();
});

return Task.CompletedTask;
}

private async Task CheckStateAsync()
{
// Client reconnected, no need to reset
if (_discord.ConnectionState == ConnectionState.Connected)
{
_logger.LogInformation("Discord client recovered");
return;
}

_logger.LogCritical("Client did not reconnect in time, restarting host");
await _host.StopAsync();
await _host.StartAsync();
}

private void Dispose(bool disposing)
{
if (disposing)
{
_logger.LogInformation("Disposing Reliability Service");
_discord.Connected -= ConnectedAsync;
_discord.Disconnected -= DisconnectedAsync;
_cts?.Cancel();
_cts?.Dispose();
}
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

~ReliableDiscordHost()
{
Dispose(false);
}
}
}
72 changes: 72 additions & 0 deletions Discord.Addons.Hosting/Reliability/ReliableHostExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Discord.WebSocket;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Discord.Addons.Hosting.Reliability
{
/// <summary>
/// Extends <see cref="IHost"/> with Discord.Net Reliability options.
/// </summary>
public static class ReliableHostExtensions
{
private static ReliableDiscordHost _reliable;
private static CancellationTokenSource _cts;

/// <summary>
/// Adds the Reliability Service and Runs the host. This function will only return if <see cref="StopReliablyAsync"/> is called elsewhere. Do not use in combination with <see cref="WithReliability"/>
/// </summary>
/// <param name="host">The host to configure.</param>
public static async Task RunReliablyAsync(this IHost host)
{
host.WithReliability();
await host.StartAsync();
_cts = new CancellationTokenSource();

AppDomain.CurrentDomain.ProcessExit += (sender, eventArgs) =>
{
_ = host.StopReliablyAsync();
};
Console.CancelKeyPress += (sender, e) => {
e.Cancel = true;
_ = host.StopReliablyAsync();
};

await Task.Delay(-1, _cts.Token).ContinueWith(_ => { });
}

/// <summary>
/// FOR ADVANCED USE ONLY: Directly adds the reliability service to the host. This may result in unexpected behaviour. For most situations you should use <see cref="RunReliablyAsync"/> instead
/// </summary>
/// <param name="host">The host to configure.</param>
internal static IHost WithReliability(this IHost host)
{
if(_reliable != null)
throw new InvalidOperationException("Cannot add Reliability Host, it already exists!");

var discord = host.Services.GetRequiredService<DiscordSocketClient>();
var logger = host.Services.GetRequiredService<ILogger<ReliableDiscordHost>>();
_reliable = new ReliableDiscordHost(discord, logger, host);

return host;
}

/// <summary>
/// Disposes the reliability service and stops the host. For use when <see cref="RunReliablyAsync"/> is used to start the host.
/// </summary>
/// <param name="host">The host to configure.</param>
public static async Task StopReliablyAsync(this IHost host)
{
if (_reliable == null)
throw new InvalidOperationException("Reliable host is null. Shutdown the host normally with StopAsync instead.");
_reliable.Dispose();
_reliable = null;
await host.StopAsync();
_cts.Cancel();
_cts.Dispose();
}
}
}
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![NuGet](https://img.shields.io/nuget/v/Discord.Addons.Hosting.svg?style=flat-square)](https://www.nuget.org/packages/Discord.Addons.Hosting)

[Discord.Net](https://github.com/RogueException/Discord.Net) hosting with [Microsoft.Extensions.Hosting](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host).
This package provides extensions to IHostBuilder that will run a Discord.Net socket/sharded client as a IHostedService.
This package primarily provides extensions to a .NET Generic Host (IHostBuilder) that will run a Discord.Net socket/sharded client as a controllable IHostedService. This simplifies initial bot creation and moves the usual boilerplate to a convenient builder pattern.

Discord.Net 2.0 build 1000 or later is required.

Expand Down Expand Up @@ -39,4 +39,29 @@ using (host)
}
```

See [samples](https://github.com/Hawxy/Discord.Addons.Hosting/tree/master/Samples) for working examples
### Basic Usage

1. Create a .NET Core application (or retrofit your existing one)
2. Add the following NuGet packages (at the absolute minimum):

```Discord.Addons.Hosting```
```Microsoft.Extensions.Hosting```
```Microsoft.Extensions.Configuration.Json```

3. Create and start your application using a HostBuilder as shown in the [examples](https://github.com/Hawxy/Discord.Addons.Hosting/tree/master/Samples)

### Serilog

Serilog should be added to the host with ```Serilog.Extensions.Hosting```.

See the Serilog [example](https://github.com/Hawxy/Discord.Addons.Hosting/tree/master/Samples/SampleBotSerilog) for usage

### Reliability

Discord.Net can occasionally fail to reconnect after an extended outage. This library provides a basic solution that will automatically attempt to restart the host on a failure. Please note that this functionality is experimental and does not guarantee that the client will *always* recover.

To use the reliability extensions, start the host with ```await host.RunReliablyAsync()```.

To shutdown the host, it's recommended to add a shutdown command to your bot and call ```host.StopReliablyAsync()```.

This behaviour is similar to the usage of ```RunAsync()``` and ```StopAsync()```
7 changes: 5 additions & 2 deletions Samples/SampleBotSerilog/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Threading.Tasks;
using Discord;
using Discord.Addons.Hosting;
using Discord.Addons.Hosting.Reliability;
using Discord.Commands;
using Discord.WebSocket;
using Microsoft.Extensions.Configuration;
Expand All @@ -14,7 +15,7 @@ namespace SampleBotSerilog
{
class Program
{
static async Task Main(string[] args)
static async Task Main()
{
//Log is available everywhere, useful for places where it isn't practical to use ILogger injection
Log.Logger = new LoggerConfiguration()
Expand Down Expand Up @@ -61,14 +62,16 @@ static async Task Main(string[] args)
.ConfigureServices((context, services) =>
{
services.AddSingleton<CommandHandler>();
});
})
.UseConsoleLifetime();

//Start and stop just by hitting enter
//See https://github.com/aspnet/Hosting/tree/master/samples/GenericHostSample for other control patterns
var host = builder.Build();
using (host)
{
await host.Services.GetRequiredService<CommandHandler>().InitializeAsync();

while (true)
{
Log.Information("Starting!");
Expand Down
Loading

0 comments on commit 75af1b2

Please sign in to comment.