-
Notifications
You must be signed in to change notification settings - Fork 27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for authentication using an Azure.Core.TokenCredential #197
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is great to see AAD auth being supported. However, I am a bit concerned with the number of breaking changes here, especially since Netherite is GA. These changes will require a 2.0 release and do we want to do that so soon? Is there a way to accomplish this in a pure additive non-breaking fashion?
@@ -114,15 +119,31 @@ NetheriteOrchestrationServiceSettings GetNetheriteOrchestrationServiceSettings(s | |||
netheriteSettings.HubName = taskHubNameOverride; | |||
} | |||
|
|||
// connections for Netherite are resolved either via an injected custom resolver, or otherwise by resolving connection names to connection strings | |||
var connectionResolver = this.serviceProvider.GetService<DurableTask.Netherite.ConnectionResolver>() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possible null reference? IServiceProvider
is not verified to be non-null.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I will add that.
@@ -48,6 +51,7 @@ public class NetheriteProviderFactory : IDurabilityProviderFactory | |||
public NetheriteProviderFactory( | |||
IOptions<DurableTaskOptions> extensionOptions, | |||
ILoggerFactory loggerFactory, | |||
IServiceProvider serviceProvider, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we take ConnectionResolver
in directly instead of IServiceProvider
?
I also recommend to split these changes into a different constructor. This will avoid breaking changes and also if there is an either/or option (provide creds this way or a different way) the two ctors can represent the two different ways.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it is a runtime breaking change - existing apps that drop in the version of this assembly will encounter MethodMissingException
as this is a different ctor signature at runtime.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This constructor is never called by any client code, it is only accessible via DI.
So this is not actually a breaking change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was careful to avoid a breaking change for DF users.
I was not careful for DTFx users though, so I will see if I can add some overloads to keep things compatible for them as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Many breaking changes can be runtime only. This will break if I take an app compiled against the previous version of DurableTask.Netherite and drop in the new dll. When following semver, you should always be able to drop in a minor version rev without needing to recompile your application.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have manually tested on the sample function app that I can drop in the new dlls (DurableTask.Netherite.AzureFunctions.dll and DurableTask.Netherite.dll) and the app still runs, without recompilation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Supporting update without recompilation for any possible situation seems like an unnecessarily high bar.
This is a very common scenario. Consider the dependency graph for all the NuGet packages a given project may bring in - many of these libraries may end up relying on the same library but different versions. Strictly avoiding breaking changes like this allows this to work. If you do introduce breaking changes, these libraries will be incompatible at runtime and cause failures.
Let's say someone has written a companion library for Netherite and has targeted a version before this change. Someone brings both that library and also directly references Netherite with these breaking changes. Depending on what that companion library does, it may be completely non-functional at runtime due to breaking changes.
This constructor is never called by any client code, it is only accessible via DI.
The type and constructor are public, so it falls under the public API and subject to breaking changes constraints. It is possible end users new up this type directly for whatever reason they want.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As for IServiceProvider
vs taking in ConnectionResolver
directly. This is mixing DI and service-locator patterns. Well, service locator is considered by some to be an anti-pattern. Ultimately, you have the final say with this. But I strongly recommend against using service-locator pattern unless it is absolutely necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also recommend to split these changes into a different constructor. This will avoid breaking changes and also if there is an either/or option (provide creds this way or a different way) the two ctors can represent the two different ways.
how does DI know which constructor to use if there is more than one?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
how does DI know which constructor to use if there is more than one?
I see you have answered this below. There is an attribute for it.
var service = new NetheriteOrchestrationService(settings, this.loggerFactory); | ||
|
||
var service = new NetheriteOrchestrationService(settings, this.loggerFactory, this.serviceProvider); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: whitespace
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok.
@@ -51,6 +51,7 @@ | |||
|
|||
<ItemGroup> | |||
<PackageReference Include="Azure.Data.Tables" Version="12.6.1" /> | |||
<PackageReference Include="Azure.Identity" Version="1.6.0" /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we are only using TokenCredential
abstraction, I think you only need Azure.Core
package.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice.
/// <summary> | ||
/// Allows customization of what settings to use for what connection. Can be used to specify TokenCredentials. | ||
/// </summary> | ||
public abstract class ConnectionSettingsCustomizer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this be removed? I don't see it used anywhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like it is dead code left over from some earlier version. Will remove it.
return ConnectionInfo.FromTokenCredential(this.tokenCredential, eventHubsNamespaceName, recourceType); | ||
|
||
default: | ||
throw new NotImplementedException("unknown resource type"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: NotSupportedException
seems more fitting.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok.
/// <summary> | ||
/// Resolves connections using a token credential and a mapping from connection names to resource names. | ||
/// </summary> | ||
public abstract class CredentialBasedConnectionNameResolver : DurableTask.Netherite.ConnectionResolver |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: file name should match type name.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok.
ResourceName = name, | ||
ConnectionString = null, | ||
TokenCredential = tokenCredential, | ||
HostName = $"{name}.servicebus.windows.net", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if we want to support Netherite in other Azure clouds we will need some follow up work for cloud-specific host names. Or can this be adjusted to take in the full hostname instead of name?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the hardcoded string here is only meant to be a convenient default.
The connection resolver can set this any other hostname.
} | ||
|
||
/// <summary> | ||
/// Creates a new instance of the OrchestrationService with default settings | ||
/// </summary> | ||
public NetheriteOrchestrationService(NetheriteOrchestrationServiceSettings settings, ILoggerFactory loggerFactory) | ||
public NetheriteOrchestrationService(NetheriteOrchestrationServiceSettings settings, ILoggerFactory loggerFactory, IServiceProvider serviceProvider = null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is preferable to take in the exact dependencies you need (to follow IoC / DI) vs IServiceProvider
.
As with the other ctor comment, I recommend avoiding a breaking change by adding a separate ctor which takes in the new params.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't prefer using a list of dependencies. It means we have to add/modify constructor overloads every time the list changes which is really inconvenient. Also, this does not work here since not all of the types are public.
How is this a breaking change? the argument is optional.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned in the other comment, it is breaking because code compiled against the previous dll will fail to find this constructor if the new version is dropped in place.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I will create an overload then.
/// The resolved storage connection string. Is never serialized or deserialized. | ||
/// </summary> | ||
[JsonIgnore] | ||
public string ResolvedStorageConnectionString { get; set; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are several breaking changes in this file. Are we okay with that? If we follow semver, that would require a major version rev.
The alternative is to try and do additive changes only - retaining old functionality as is and only adding it what you need for the new functionality.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can see how much work is required to maintain compatibility. Perhaps keeping some deprecated ResolvedConnectionString fields is enough.
Note that this affects only DTFx users, for DF users there are no breaking changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will add a compatibility shim so there are no breaking changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, avoiding breaking changes is very tricky. Here is a list of what is and is not allowed: https://learn.microsoft.com/en-us/dotnet/core/compatibility/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To avoid breaking changes in this PR, I suggest making sure everything is purely additive. Do not remove or change existing method signatures or properties - only add new ones. The implementations then need to make decisions at runtime for which property to use. You can update the getting/setting implementations though. So, an example of what you are trying to update could be like:
Before:
public string OldConnectionString { get; set; }
After:
public ComplexConnectionType NewConnectionWay { get; set; }
public string OldConnectionString
{
get => NewConnectionWay?.ConnectionString;
set => NewConnectionWay = new ComplexConnectiontype(value); // also need some logic around going from non-null to null
}
public class ComplexConnectionType
{
public ComplexConnectionType(string connectionString) { } // a ctor for the connection string scenario
public ComplexConnectionType(TokenCredential credential) { } // a ctor for the new recommended way. Just an example implementation - format this however you need.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can then remove the shim when we go to 2.0
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are no more breaking changes here.
…quiring recompilation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some comments for now. These are all up to you if you want to follow them or not.
@@ -48,6 +51,7 @@ public class NetheriteProviderFactory : IDurabilityProviderFactory | |||
public NetheriteProviderFactory( | |||
IOptions<DurableTaskOptions> extensionOptions, | |||
ILoggerFactory loggerFactory, | |||
IServiceProvider serviceProvider, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Supporting update without recompilation for any possible situation seems like an unnecessarily high bar.
This is a very common scenario. Consider the dependency graph for all the NuGet packages a given project may bring in - many of these libraries may end up relying on the same library but different versions. Strictly avoiding breaking changes like this allows this to work. If you do introduce breaking changes, these libraries will be incompatible at runtime and cause failures.
Let's say someone has written a companion library for Netherite and has targeted a version before this change. Someone brings both that library and also directly references Netherite with these breaking changes. Depending on what that companion library does, it may be completely non-functional at runtime due to breaking changes.
This constructor is never called by any client code, it is only accessible via DI.
The type and constructor are public, so it falls under the public API and subject to breaking changes constraints. It is possible end users new up this type directly for whatever reason they want.
@@ -48,6 +51,7 @@ public class NetheriteProviderFactory : IDurabilityProviderFactory | |||
public NetheriteProviderFactory( | |||
IOptions<DurableTaskOptions> extensionOptions, | |||
ILoggerFactory loggerFactory, | |||
IServiceProvider serviceProvider, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As for IServiceProvider
vs taking in ConnectionResolver
directly. This is mixing DI and service-locator patterns. Well, service locator is considered by some to be an anti-pattern. Ultimately, you have the final say with this. But I strongly recommend against using service-locator pattern unless it is absolutely necessary.
@@ -114,15 +118,31 @@ NetheriteOrchestrationServiceSettings GetNetheriteOrchestrationServiceSettings(s | |||
netheriteSettings.HubName = taskHubNameOverride; | |||
} | |||
|
|||
// connections for Netherite are resolved either via an injected custom resolver, or otherwise by resolving connection names to connection strings | |||
var connectionResolver = this.serviceProvider?.GetService<DurableTask.Netherite.ConnectionResolver>() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there is an opportunity to follow DI/IoC with the connection resolver, but still make it ultimately "optional" for customers. The idea would be to move this kind of logic to the DI setup code. I'll have to take a closer look at what existing DI setup code Netherite has, but I think we can definitely get something working.
For example, is INameResolver
even used directly here, or is it only used within the ConnectionResolver
? If it is only used within the connection resolver, we can do the following:
- Add a 2nd constructor which takes in
ConnectionResolver
and noINameResolver
. - The ctor which still has
INameResolver
just calls that new ctor with thenew ConnectionNameToConnectionStringResolver((name) => nameResolver.Resolve(name))
as the connection resolver.- No longer have a
INameResolver
field. Which is why it is important INameResolver is not used directly anymore. If we still need it directly, will need to rethink this aproach.
- No longer have a
- Add
[ActivatorUtilitiesConstructor]
to the new connection resolver ctor (so it is used for DI). - Update the DI setup code to use
TryAddSingleton<ConnectionResolver>
with a factory which will pullINameResolver
from the service container and returnnew ConnectionNameToConnectionStringResolver((name) => nameResolver.Resolve(name))
as well.- at this point, maybe a constructor for
ConnectionNameToConnectionStringResolver
which takes inINameResolver
may be nice.
- at this point, maybe a constructor for
What we now have is no observable difference between old and new. However, customers can now inject ConnectionResolver
to the DI container and it will be consumed as expected, but we have maintained DI/IoC principals.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
However, customers can now inject ConnectionResolver to the DI container
I don't quite understand how they can inject a ConnectionResolver if we already injected our own during our own setup
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Empirically, it does seem to work...
- The TokenCredentialDF.FunctionStartup calls
builder.Services.AddSingleton<DurableTask.Netherite.ConnectionResolver, MyConnectionResolver>();
- The DurableTask.Netherite.AzureFunctions.NetheriteProviderStartup calls
builder.Services.TryAddSingleton<ConnectionResolver, NameResolverBasedConnectionNameResolver>();
and magically the right things happen. Did I just get lucky or is the DI smart enough to know which one should take precedence?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we still need it directly, will need to rethink this aproach
I still need it, but that does not really change anything I think. I just leave it there, and go ahead with the rest of the suggestions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is happening is builder.Services.TryAddSingleton<ConnectionResolver, NameResolverBasedConnectionNameResolver>();
will first check a service for ConnectionResolver
is already registered, only adding NameResolverBasedConnectionNameResolver
if no ConnectionResolver
is already registered. This gives end users control over the which instance is registered: if they run before you, they can just call AddSingleton
. If they run after, they will first need to remove your service registration then add their own (this is possible, IServiceCollection
is a fully mutable collection with add/remove semantics).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apparently it is not necessary for users to make a distinction of what order the code is running in.
I stepped through the sample in the debugger to see why this is working... what I observed is that the user code is running after me and calls AddSingleton, which I would have expected to cause some problem. Instead, everything ends up working as desired. The debugger shows that as a result, immediately after this step, there are two services of this type in the collection:
And when the NetheriteProviderFactory is constructed by DI, it is given the second one of these services, which is the desired result.
It is a bit strange, but perhaps no need to worry since it is working correctly.
using Microsoft.Azure.Storage; | ||
|
||
/// <summary> | ||
/// Internal abstraction used for capturing connection information and credentials. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Considering this type is public
, should "Internal" be removed from the comment?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes
/// <summary> | ||
/// Utilities for constructing various SDK objects from a connection information. | ||
/// </summary> | ||
public static class ConnectionInfoExtensions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this need to be public
? With all the breaking change discussions in this PR, I recommend only making types public
if they are expected to be consumed by end users.
If they need to be public
for cross-assembly usage within this repo, we can use internals-visible-to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These methods are useful if you want to slightly change something (e.g. the host URL) without having to rewrite all of it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think they can be internal, yes.
/// A utility class for constructing a <see cref="Microsoft.Azure.Storage.Auth.TokenCredential"/> | ||
/// from a <see cref="Azure.Core.TokenCredential"/>. | ||
/// </summary> | ||
public static class CredentialShim |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider making this internal
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes.
@@ -24,9 +24,9 @@ public static class EventHubsUtil | |||
/// <param name="eventHubName">The name of the event hub.</param> | |||
/// <param name="partitionCount">The number of partitions to create, if the event hub does not already exist.</param> | |||
/// <returns>true if the event hub was created.</returns> | |||
public static async Task<bool> EnsureEventHubExistsAsync(string connectionString, string eventHubName, int partitionCount) | |||
public static async Task<bool> EnsureEventHubExistsAsync(ConnectionInfo info, string eventHubName, int partitionCount, CancellationToken cancellationToken) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is technically a public API contract, but I get this was never meant for public use. Use your judgement here - do you make it separate methods to avoid breaking change or not?
In the next major version rev I definitely recommend evaluating public API surface and making all utilities like this and other implementation details classes internal
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, I was not particularly aware of how ANY public API surface becomes a liability. We can definitely retire some of these in the next major version change.
/// <summary> | ||
/// Resolves connections using a token credential, a storage account name, and an eventhubs namespace name. | ||
/// </summary> | ||
public class SimpleCredentialResolver : ConnectionResolver |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A pattern I like to use for base-class -> "simple/default/recommended" implementation, is to make the concrete type a private nested class of the base type, then add a static method ConnectionResolver.FromCredential(...)
to get an instance of that class. I like this for two reasons:
- Discoverability - users will see they need a
ConnectionResolver
and then using intellisense can see an easy way to get one right on that abstract type (theFromCredential
method). - Public API - we are not committing an entire new type to the public API surface, but rather a single static method. Much more flexible to changes in the futrue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that seems like a good way to do it. Will adjust.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense. Though I prefer to keep the actual classes in their own file. Just made them internal and added public factory methods to the base class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That approach is completely fine as well. I keep them in their own file as well - but I make the outer class partial
and move the nested class to its own file. So, it would be ConnectionResolver.SimpleCredentialResolver.cs
.
But not need for that if you already made it internal
.
/// An exception that indicates configuration errors for Netherite. | ||
/// </summary> | ||
[Serializable()] | ||
public class NetheriteConfigurationException : System.Exception |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned, the options pattern does have a validation exception type in it: https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.options.optionsvalidationexception?view=dotnet-plat-ext-6.0
With some refactoring, could leverage the options pattern more directly to avoid having to recreate this ourselves.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will leave the refactoring of the settings into an options pattern up to a future PR (perhaps for a major version change). It is not a goal of this PR to align Netherite with best DI practices.
/// <summary> | ||
/// Creates a new instance of the OrchestrationService with default settings | ||
/// </summary> | ||
public NetheriteOrchestrationService(NetheriteOrchestrationServiceSettings settings, ILoggerFactory loggerFactory, IServiceProvider serviceProvider) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As with the NetheriteProviderFactory
, I recommend using DI/IoC over service-locator.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand that the service locator may not always be the preferred solution, but I think it is appropriate in this situation.
- Not all of the services are public, so it is not just a simple case of listing them in the constructor argument.
- Constructors that list all the dependencies create maintenance complexity. In some sense, this is the problem DI is solving in the first place so it seems sad to give up on that. Also, I really dislike having lots of overloads, it makes the code hard to read.
Using a service locator instead helps to keep the constructor stable because new dependencies can be found without having to be listed explicitly.
From what I understand, the danger of the service locator pattern is that the dependency ordering is not visible to the DI startup logic, so it cannot correctly order dependencies during startup. But that is not an issue here because the orchestration service is never constructed at that point. Only the factories are constructed at startup time.
There are no more breaking changes, and I have revised some of the DI patterns as recommended. However, I kept the service-locator pattern in one specific situation because I did not like the alternatives. I also added README files to the samples so users understand better what is being demonstrated and how to run them. |
/// <summary> | ||
/// This constructor should not be called directly. It is here only to avoid breaking changes. | ||
/// </summary> | ||
[Obsolete] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Obsolete'ing APIs has some differing viewpoints. .NETs policy is as follows:
An API that shipped in a long-term support (LTS) release must be obsoleted in the subsequent LTS release before it can be removed. In rare cases, exceptions are made to obsolete an API before the subsequent LTS release based on business needs. All obsoletions are documented and communicated to customers.
It takes two LTS releases to remove a public API! First one marks it obsolete, second removes. This is because technically adding [Obsolete]
can be viewed as a compilation breaking change if the project has WarnAsError=true
.
Buuuut, I think in this case you are fine. This is just more of a fun FYI to express how "fun" maintaining public APIs is 😆
/// Creates a connection resolver for a given connection string lookup function. | ||
/// </summary> | ||
/// <param name="connectionStringLookup">A function that maps connection names to connection strings.</param> | ||
public static ConnectionResolver FromConnectionNameToConnectionStringResolver(Func<string, string> connectionStringLookup) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a more concise name we can use? We can at least drop Resolver
suffix, as that is already expressed in the type ("ConnectionResolver"). For a more concise name, maybe FromNamedConnectionString
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I prefer the verbose name here. I find it helpful if implementations of an abstract class share the last word with the superclass.
/// Applies the default suffixes (*.core.windows.net for storage accounts and *.servicebus.windows.net for event hubs). | ||
/// </summary> | ||
/// <param name="tokenCredential">The token credential to use.</param> | ||
/// <param name="storageAccountName">The name of the storage account, or null if using in-memory emulation.</param> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does in-memory require a credential at all? Should an explicit separate method be added instead ForInMemory()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I suppose that makes sense.
/// <param name="tokenCredential">The token credential to use.</param> | ||
/// <param name="storageAccountName">The name of the storage account, or null if using in-memory emulation.</param> | ||
/// <param name="eventHubNamespaceName">The name of the event hub namespace, or null if using the singlehost configuration.</param> | ||
public static ConnectionResolver FromTokenCredentialAndResourceNames(Azure.Core.TokenCredential tokenCredential, string storageAccountName = null, string eventHubNamespaceName = null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think of dropping "AndResourceNames"? FromTokenCredential(...)
sounds fine to me, even including the resource name parameters.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sure.
The following mechanisms are introduced:
in NetheriteOrchestrationServiceSettings, we replaced "ResolvedConnectionString" with a new class "ConnectionInfo". This is a class that contains enough information to construct objects needed for connections (e.g. SDK clients), such as connection strings, OR token credentials and endpoint URI information. We use separate ConnectionInfo members for all types of connections (table storage, blob storage, page blob storage, eventhubs).
Implement ConnectionInfo extension methods that can construct various SDK objects (v12 table clients, v11 storage accounts, EventHubClient, EventProcessorHost) and authenticate HTTP requests
Create a new abstract class 'ConnectionResolver' that resolves the required ConnectionInfo objects before starting the orchestration service. A
ConnectionResolver
must be run on the settings object before the service can be started. DTFx clients call this explicitly (since they are constructing a settings object in code). DF clients must inject a connection resolver during startup, since they cannot access the settings object directly. This class has several implementations suited for different use cases, and can also be implemented by users directly if desired:ConnectionNameToConnectionStringResolver
represents the currently implemented way in which names are resolved to connection strings. This is used by default, if the user does not inject a different resolverSimpleCredentialResolver
has a constructor that takes anAzure.Core.TokenCredential
and two names (for storage account and event hubs namespace). It is intended for convenient use by DTFx applications.TokenCredentialResolver
is an abstract class whose constructor takes anAzure.Core.TokenCredential
and that must be overriden to map connection names to resource names (storage account and event hub name). It is intended to be used with DF's current mechanism where connection names are a application-visible concept (e.g. you can specify a particular connection to be used for a DurableClient).Added two samples that demonstrate how to use the new mechanism for DF and DTFx, respectively.