Spinner

ASP.NET Core - ConfigurationManager RIP

.NET Core 1.0 has arrived and overall it has been well received by the community. The cross-platform advantages outweigh the learning curve needed to get started and be productive. The C# language you know and love is still your primary tool and it only keeps getting better. The Framework has gone through a bit of a reorganization to make it more portable and modular. Several namespaces have changed which surely brings some frustration but thanks to the magic of CTRL+. and this terrific online namespace mapping tool, the learning curve is not too steep.

A noteworthy, radical change, refers to the way application settings are managed. If you are used to doing ConfigurationManager.AppSettings["MySettingKey"], then you are out of luck. Doing that directly in your Controllers was a bad idea anyway and you probably should have been accessing those settings via an abstraction. Nevertheless, the new paradigm brings in the following advantages:

ASP.NET Core 1.0 is the evolution of .NET into the cross-platform space. The image below exemplifies how the frameworks stack against each other in the evolutionary ladder.

There is a bit more initial configuration but the end result is cleaner and more consistent with C# best practices. I will attempt to illustrate the process with a practical example used at Planet Diego.
Example: A list of image files is retrieved from an Amazon AWS S3 bucket. The order of the files within the list is randomized and a specific quantity is extracted (dictated by an application setting). The resulting list is rendered on a View as part of a JSON structure to be consumed by a client-side script that initiates a slide image transition between the image files in the JSON array.

Lets begin with appsettings.json, this is the new file in which application settings are stored, no more Web.config or App.config.

{
  "AppSettings": {
    "GoogleTrackingId": "your-google-analytics-tracking-id",
    "EmailSender": "no-reply@yourdomain.com",
    "Captcha": {
      "SiteKey": "your-reCAPTCHA-site-key",
      "SecretKey": "your-reCAPTCHA-secret-key"
    },
    "CDNDomain": "cdn.planetdiego.com",
    "HomeGalleryKey": "web/home/",
    "HomeImageDefault": "img/person-img.jpg",
    "HomeImageCacheSeconds": 180.0,
    "HomeImageRotateSeconds": 24,
    "HomeImageMaxPicks": 30
  },
  "AWS": {
    "S3": {
      "BucketName": "dot-blog"
    },
    "ProfileName": "development",
    "Region": "us-east-1",
    "AccessKey": "your-aws-access-key",
    "SecretKey": "your-aws-secret-key"
  },
  "ApplicationInsights": {
    "InstrumentationKey": "your-instrumentation-key"
  },
  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Debug",
      "System": "Information",
      "Microsoft": "Information"
    }
  }
}

One great feature of this new structure is that production keys can be easily overridden by creating another file called appsettings.Production.json. Each configured environment can have its own specific overrides by using this convention. To understand how this works, see the contents of the Startup.cs file, especially line 6 in which the optional settings file containing the overrides and/or additions is loaded.

public Startup(IHostingEnvironment env)
{
    var builder = new ConfigurationBuilder()
        .SetBasePath(env.ContentRootPath)
        .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
        .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
        .AddEnvironmentVariables();

    if (env.IsDevelopment())
    {
        // This will push telemetry data through Application Insights pipeline faster, allowing you to view results immediately.
        builder.AddApplicationInsightsSettings(developerMode: true);
    }
    Configuration = builder.Build();
}

The next step is to create a POCO so we can map the JSON contents to a strongly-typed object. We will do so for the AppSettings and AWS section of the JSON appsettings file. The following exemplifies how the nested structure is mapped and how you segregate multiple sections of the settings file.

public class AppSettings
{
    public string GoogleTrackingId { get; set; }
    public string EmailRecipient { get; set; }
    public Captcha Captcha { get; set; }
    public string CDNDomain { get; set; }
    public string HomeGalleryKey { get; set; }
    public string HomeImageDefault { get; set; }
    public double HomeImageCacheSeconds { get; set; }
    public double HomeImageRotateSeconds { get; set; }
    public int HomeImageMaxPicks { get; set; }
}

public class Captcha
{
    public string SiteKey { get; set; }
    public string SecretKey { get; set; }
}

public class AWS
{
    public string ProfileName { get; set; }
    public string Region { get; set; }
    public string AccessKey { get; set; }
    public string SecretKey { get; set; }
    public S3 S3 { get; set; }
}

public class S3
{
    public string BucketName { get; set; }
}

Next comes the magic that glues everything together. If you are familiar with dependency injection, this is completely natural to you, otherwise, please read on the subject to better understand how Inversion of Controls (IoC) work. Libraries like Autofac, Ninject, StructureMap and Unity are popular .NET solutions. Nevertheless, ASP.NET Core 1.0 has a built-in solution which we will leverage for this example. In Startup.cs look for the ConfigureServices method. Take a look at lines 4 & 5 in which we map our POCO's to the sections in the JSON file. That is really all that is needed, now those dependencies will be injected wherever we need them.

// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
    services.Configure<AppSettings>(Configuration.GetSection("AppSettings"));
    services.Configure<AWS>(Configuration.GetSection("AWS"));
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
    services.AddTransient<IStorageService, S3StorageService>();

    services.AddMvc();

    // Add any other application services here...
}

It is important to note that the class injected is of type IOptions<AppSettings>. The IOptions interface is part of the Microsoft.Extensions.Options namespace. In order to access its contents, use the Value property on the instance. Furthermore, the instance is essentially a singleton which is passed throughout the lifetime of the application. Let's review how this works with a practical example in which the AWS section of the AppSettings is injected into the class constructor.

public interface IStorageService
{
    Task<IList<string>> List(string startingKey = "", int maxKeys = 100, bool hasEmptyObjects = false);
}

public class S3StorageService : IStorageService
{
    private readonly AWS _settings;
    private readonly RegionEndpoint _region;

    public S3StorageService(IOptions<AWS> settings)
    {
        _settings = settings.Value;
        _region = RegionEndpoint.GetBySystemName(_settings.Region);
    }

    public async Task<IList<string>> List(string startingKey = "", int maxKeys = 100, bool hasEmptyObjects = false)
    {
        var result = new List<string>();

        using (var client = new AmazonS3Client(
                _settings.AccessKey,
                _settings.SecretKey,
                _region))
        {

            try
            {
                ListObjectsV2Request request = new ListObjectsV2Request
                {
                    BucketName = _settings.S3.BucketName,
                    MaxKeys = maxKeys,
                    Prefix = startingKey
                };
                ListObjectsV2Response response;
                do
                {
                    response = await client.ListObjectsV2Async(request);

                    // Process response.
                    foreach (S3Object entry in response.S3Objects)
                    {
                        if (entry.Size > 0 || hasEmptyObjects)
                            result.Add(entry.Key);
                    }

                    Debug.WriteLine("Next Continuation Token: {0}", response.NextContinuationToken);
                    request.ContinuationToken = response.NextContinuationToken;

                } while (response.IsTruncated == true);
            }
            catch (AmazonS3Exception amazonS3Exception)
            {
                if (amazonS3Exception.ErrorCode != null &&
                    (amazonS3Exception.ErrorCode.Equals("InvalidAccessKeyId")
                    ||
                    amazonS3Exception.ErrorCode.Equals("InvalidSecurity")))
                {
                    Debug.WriteLine("Check the provided AWS Credentials.");
                }
                else
                {
                    Debug.WriteLine("Error occurred. Message:'{0}' when listing objects",
                     amazonS3Exception.Message);
                }
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex.Message);
            }

        }

        return result;
    }
}

Notice how the AWS settings are injected into the constructor and assigned to a private field for use within the class. The async List method returns a promise for a list of files in the given S3 bucket and starting key. Make sure you install the AWSSDK.S3 Nuget package to work with Amazon S3 and be able to use the code above.
The same applies to the Home controller. Below we inject the AppSettings section and the S3StorageService class via its implemented interface (see line 7 as part of the ConfigureServices method). To spice things up we randomize the order of the returned files using an extension method and then grab up to the maximum number allowed specified on the settings value HomeImageMaxPicks.

public class HomeController : Controller
{
    private readonly AppSettings _appSettings;
    private readonly IStorageService _storage;

    public HomeController(
        IOptions<AppSettings> appSettings,
        IStorageService storage
        )
    {
        _appSettings = appSettings.Value;
        _storage = storage;
    }

    public async Task<IActionResult> Index()
    {
        var vm = new HomeViewModel();

        vm.MainImageDefault = _appSettings.HomeImageDefault;
        vm.MainImageRotateEvery = _appSettings.HomeImageRotateSeconds;
        vm.MainImageBase = string.Concat("//", _appSettings.CDNDomain, "/");

        var images = await _storage.List(_appSettings.HomeGalleryKey);

        if (images.Any())
        {
            vm.MainImageKeys = images.Randomize().Take(_appSettings.HomeImageMaxPicks).ToList();
        }

        return View(vm);
    }
}

public class HomeViewModel
{
    public string MainImageDefault { get; set; }
    public string MainImageBase { get; set; }
    public IList<string> MainImageKeys { get; set; }
    public double MainImageRotateEvery { get; set; }

    public HomeViewModel()
    {
        MainImageKeys = new List<string>();
    }
}

public static class EnumerableExtensions
{
    private static Random _rnd = new Random();

    public static IEnumerable<T> Randomize<T>(this IEnumerable<T> source)
    {
        return source.OrderBy<T, int>((item) => _rnd.Next());
    }
}

Now onto the last step. In addition to the ViewModel, we will be accessing some application settings too in order to exemplify how dependency injection also applies to views. There is a special Razor file, _ViewImports.cshtml, in the Views folder which allows you to add namespaces that could be accessed by all views. However, it can also be used to perform dependency injection using the keyword inject.

@using MyApp.Web;
@using MyApp.ViewModels;

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

@inject Microsoft.Extensions.Options.IOptions<MyApp.Models.AppSettings> AppSettings

Notice how in the last line we inject AppSettings which gives us access to the class on all views.
Now, on the view itself, we will be mapping some settings and parts of the ViewModel to Javascript variables so they can be easily consumed client-side. The code below is a Partial View invoked from the Home/Index View with @{Html.RenderPartial("_JsInit", Model);}.

@model HomeViewModel

<script type="text/javascript">
    // Global Application Namespace:
    var myApp = myApp || {};

    myApp = {
        app: {
            siteRoot: '@Url.Content("~")',
            cdn: '@AppSettings.Value.CDNDomain',
            gaId: '@AppSettings.Value.GoogleTrackingId'
        },
        widgets: {
            reCaptchaSiteKey: '@AppSettings.Value.Captcha.SiteKey'
        },
        mainImages: {
            fallback: '@Model.MainImageDefault',
            rotateEvery: @Model.MainImageRotateEvery,
            base: '@Model.MainImageBase',
            keys: @Html.Raw(Json.Serialize(Model.MainImageKeys))
        }
    };
</script>

The above functionality is a subset, for illustration purposes, of the functionality implemented on Planet Diego.

Summary

.NET Core brings strongly typed configuration objects into the mix. It finally eliminates the plumbing code necessary to implement such functionality in a .NET idiomatic fashion. Furthermore, coupled with dependency injection, it facilitates the way we access the information. Overall, I am glad to see this functionality baked in natively. The implementation requires a bit more orchestration but in the end, it forces developers to produce cleaner and more modular code. Happy coding!

No comments: