Skip to content

Commit

Permalink
Activate ChangeToken when Ocelot's configuration changes #1037
Browse files Browse the repository at this point in the history
* Add configuration change token (#1036)

* Add IOptionsMonitor<IInternalConfiguration>

* Activate change token from *ConfigurationRepository instead of FileAndInternalConfigurationSetter; add acceptance & integration tests

* Update documentation

* Use IWebHostEnvironment as IHostingEnvironment deprecated

Co-authored-by: Chris Swinchatt <[email protected]>
  • Loading branch information
TomPallister and ChrisSwinchatt authored Feb 4, 2020
1 parent 473d50f commit 86e8d66
Show file tree
Hide file tree
Showing 17 changed files with 1,546 additions and 1,111 deletions.
510 changes: 280 additions & 230 deletions docs/features/configuration.rst

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Ocelot.Configuration.ChangeTracking
{
using Microsoft.Extensions.Primitives;

/// <summary>
/// <see cref="IChangeToken" /> source which is activated when Ocelot's configuration is changed.
/// </summary>
public interface IOcelotConfigurationChangeTokenSource
{
IChangeToken ChangeToken { get; }

void Activate();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
namespace Ocelot.Configuration.ChangeTracking
{
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Primitives;

public class OcelotConfigurationChangeToken : IChangeToken
{
public const double PollingIntervalSeconds = 1;

private readonly ICollection<CallbackWrapper> _callbacks = new List<CallbackWrapper>();
private readonly object _lock = new object();
private DateTime? _timeChanged;

public IDisposable RegisterChangeCallback(Action<object> callback, object state)
{
lock (_lock)
{
var wrapper = new CallbackWrapper(callback, state, _callbacks, _lock);
_callbacks.Add(wrapper);
return wrapper;
}
}

public void Activate()
{
lock (_lock)
{
_timeChanged = DateTime.UtcNow;
foreach (var wrapper in _callbacks)
{
wrapper.Invoke();
}
}
}

// Token stays active for PollingIntervalSeconds after a change (could be parameterised) - otherwise HasChanged would be true forever.
// Taking suggestions for better ways to reset HasChanged back to false.
public bool HasChanged => _timeChanged.HasValue && (DateTime.UtcNow - _timeChanged.Value).TotalSeconds < PollingIntervalSeconds;

public bool ActiveChangeCallbacks => true;

private class CallbackWrapper : IDisposable
{
private readonly ICollection<CallbackWrapper> _callbacks;
private readonly object _lock;

public CallbackWrapper(Action<object> callback, object state, ICollection<CallbackWrapper> callbacks, object @lock)
{
_callbacks = callbacks;
_lock = @lock;
Callback = callback;
State = state;
}

public void Invoke()
{
Callback.Invoke(State);
}

public void Dispose()
{
lock (_lock)
{
_callbacks.Remove(this);
}
}

public Action<object> Callback { get; }

public object State { get; }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Ocelot.Configuration.ChangeTracking
{
using Microsoft.Extensions.Primitives;

public class OcelotConfigurationChangeTokenSource : IOcelotConfigurationChangeTokenSource
{
private readonly OcelotConfigurationChangeToken _changeToken = new OcelotConfigurationChangeToken();

public IChangeToken ChangeToken => _changeToken;

public void Activate()
{
_changeToken.Activate();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace Ocelot.Configuration.ChangeTracking
{
using System;
using Microsoft.Extensions.Options;
using Ocelot.Configuration.Repository;

public class OcelotConfigurationMonitor : IOptionsMonitor<IInternalConfiguration>
{
private readonly IOcelotConfigurationChangeTokenSource _changeTokenSource;
private readonly IInternalConfigurationRepository _repo;

public OcelotConfigurationMonitor(IInternalConfigurationRepository repo, IOcelotConfigurationChangeTokenSource changeTokenSource)
{
_changeTokenSource = changeTokenSource;
_repo = repo;
}

public IInternalConfiguration Get(string name)
{
return _repo.Get().Data;
}

public IDisposable OnChange(Action<IInternalConfiguration, string> listener)
{
return _changeTokenSource.ChangeToken.RegisterChangeCallback(_ => listener(CurrentValue, ""), null);
}

public IInternalConfiguration CurrentValue => _repo.Get().Data;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@
using Ocelot.Responses;
using System;
using System.Threading.Tasks;
using Ocelot.Configuration.ChangeTracking;

namespace Ocelot.Configuration.Repository
{
public class DiskFileConfigurationRepository : IFileConfigurationRepository
{
private readonly IOcelotConfigurationChangeTokenSource _changeTokenSource;
private readonly string _environmentFilePath;
private readonly string _ocelotFilePath;
private static readonly object _lock = new object();
private const string ConfigurationFileName = "ocelot";

public DiskFileConfigurationRepository(IWebHostEnvironment hostingEnvironment)
public DiskFileConfigurationRepository(IWebHostEnvironment hostingEnvironment, IOcelotConfigurationChangeTokenSource changeTokenSource)
{
_changeTokenSource = changeTokenSource;
_environmentFilePath = $"{AppContext.BaseDirectory}{ConfigurationFileName}{(string.IsNullOrEmpty(hostingEnvironment.EnvironmentName) ? string.Empty : ".")}{hostingEnvironment.EnvironmentName}.json";

_ocelotFilePath = $"{AppContext.BaseDirectory}{ConfigurationFileName}.json";
Expand Down Expand Up @@ -56,6 +59,7 @@ public Task<Response> Set(FileConfiguration fileConfiguration)
System.IO.File.WriteAllText(_ocelotFilePath, jsonConfiguration);
}

_changeTokenSource.Activate();
return Task.FromResult<Response>(new OkResponse());
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Ocelot.Responses;
using Ocelot.Configuration.ChangeTracking;
using Ocelot.Responses;

namespace Ocelot.Configuration.Repository
{
Expand All @@ -10,6 +11,12 @@ public class InMemoryInternalConfigurationRepository : IInternalConfigurationRep
private static readonly object LockObject = new object();

private IInternalConfiguration _internalConfiguration;
private readonly IOcelotConfigurationChangeTokenSource _changeTokenSource;

public InMemoryInternalConfigurationRepository(IOcelotConfigurationChangeTokenSource changeTokenSource)
{
_changeTokenSource = changeTokenSource;
}

public Response<IInternalConfiguration> Get()
{
Expand All @@ -23,6 +30,7 @@ public Response AddOrReplace(IInternalConfiguration internalConfiguration)
_internalConfiguration = internalConfiguration;
}

_changeTokenSource.Activate();
return new OkResponse();
}
}
Expand Down
9 changes: 7 additions & 2 deletions src/Ocelot/DependencyInjection/OcelotBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using Ocelot.Configuration.ChangeTracking;

namespace Ocelot.DependencyInjection
{
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Ocelot.Authorisation;
using Ocelot.Cache;
using Ocelot.Claims;
Expand Down Expand Up @@ -112,6 +115,8 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo
Services.TryAddSingleton<IDownstreamAddressesCreator, DownstreamAddressesCreator>();
Services.TryAddSingleton<IDelegatingHandlerHandlerFactory, DelegatingHandlerHandlerFactory>();
Services.TryAddSingleton<ICacheKeyGenerator, CacheKeyGenerator>();
Services.TryAddSingleton<IOcelotConfigurationChangeTokenSource, OcelotConfigurationChangeTokenSource>();
Services.TryAddSingleton<IOptionsMonitor<IInternalConfiguration>, OcelotConfigurationMonitor>();

// see this for why we register this as singleton http://stackoverflow.com/questions/37371264/invalidoperationexception-unable-to-resolve-service-for-type-microsoft-aspnetc
// could maybe use a scoped data repository
Expand Down Expand Up @@ -215,7 +220,7 @@ public IOcelotBuilder AddConfigPlaceholders()
{
// see: https://greatrexpectations.com/2018/10/25/decorators-in-net-core-with-dependency-injection
var wrappedDescriptor = Services.First(x => x.ServiceType == typeof(IPlaceholders));

var objectFactory = ActivatorUtilities.CreateFactory(
typeof(ConfigAwarePlaceholders),
new[] { typeof(IPlaceholders) });
Expand All @@ -229,7 +234,7 @@ public IOcelotBuilder AddConfigPlaceholders()

return this;
}

private static object CreateInstance(IServiceProvider services, ServiceDescriptor descriptor)
{
if (descriptor.ImplementationInstance != null)
Expand Down
2 changes: 1 addition & 1 deletion src/Ocelot/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@
[assembly: ComVisible(false)]

// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("d6df4206-0dba-41d8-884d-c3e08290fdbb")]
[assembly: Guid("d6df4206-0dba-41d8-884d-c3e08290fdbb")]
28 changes: 28 additions & 0 deletions test/Ocelot.AcceptanceTests/ConfigurationReloadTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Ocelot.Configuration.File;
using System;
using Ocelot.Configuration.ChangeTracking;
using TestStack.BDDfy;
using Xunit;

Expand Down Expand Up @@ -54,6 +55,33 @@ public void should_not_reload_config_on_change()
.BDDfy();
}

[Fact]
public void should_trigger_change_token_on_change()
{
this.Given(x => _steps.GivenThereIsAConfiguration(_initialConfig))
.And(x => _steps.GivenOcelotIsRunningReloadingConfig(true))
.And(x => _steps.GivenIHaveAChangeToken())
.And(x => _steps.GivenThereIsAConfiguration(_anotherConfig))
.And(x => _steps.GivenIWait(MillisecondsToWaitForChangeToken))
.Then(x => _steps.TheChangeTokenShouldBeActive(true))
.BDDfy();
}

[Fact]
public void should_not_trigger_change_token_with_no_change()
{
this.Given(x => _steps.GivenThereIsAConfiguration(_initialConfig))
.And(x => _steps.GivenOcelotIsRunningReloadingConfig(false))
.And(x => _steps.GivenIHaveAChangeToken())
.And(x => _steps.GivenIWait(MillisecondsToWaitForChangeToken)) // Wait for prior activation to expire.
.And(x => _steps.GivenThereIsAConfiguration(_anotherConfig))
.And(x => _steps.GivenIWait(MillisecondsToWaitForChangeToken))
.Then(x => _steps.TheChangeTokenShouldBeActive(false))
.BDDfy();
}

private const int MillisecondsToWaitForChangeToken = (int) (OcelotConfigurationChangeToken.PollingIntervalSeconds*1000) - 100;

public void Dispose()
{
_steps.Dispose();
Expand Down
15 changes: 14 additions & 1 deletion test/Ocelot.AcceptanceTests/Steps.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace Ocelot.AcceptanceTests
using Ocelot.Configuration.ChangeTracking;

namespace Ocelot.AcceptanceTests
{
using Caching;
using Configuration.Repository;
Expand Down Expand Up @@ -54,6 +56,7 @@ public class Steps : IDisposable
private IWebHostBuilder _webHostBuilder;
private WebHostBuilder _ocelotBuilder;
private IWebHost _ocelotHost;
private IOcelotConfigurationChangeTokenSource _changeToken;

public Steps()
{
Expand Down Expand Up @@ -216,6 +219,11 @@ public void GivenOcelotIsRunningReloadingConfig(bool shouldReload)
_ocelotClient = _ocelotServer.CreateClient();
}

public void GivenIHaveAChangeToken()
{
_changeToken = _ocelotServer.Host.Services.GetRequiredService<IOcelotConfigurationChangeTokenSource>();
}

/// <summary>
/// This is annoying cos it should be in the constructor but we need to set up the file before calling startup so its a step.
/// </summary>
Expand Down Expand Up @@ -1123,6 +1131,11 @@ public void GivenOcelotIsRunningWithBlowingUpDiskRepo(IFileConfigurationReposito
_ocelotClient = _ocelotServer.CreateClient();
}

public void TheChangeTokenShouldBeActive(bool itShouldBeActive)
{
_changeToken.ChangeToken.HasChanged.ShouldBe(itShouldBeActive);
}

public void GivenOcelotIsRunningWithLogger()
{
_webHostBuilder = new WebHostBuilder();
Expand Down
Loading

0 comments on commit 86e8d66

Please sign in to comment.