data.mdx.frontmatter.hero_image

Własne Rozszerzenia Konfiguracji

2023-03-10 | .NET, AWS | bd90

Zgodnie z tym co zapowiadałem w ostatnim artykule dzisiaj zajmiemy się czymś nowym. Wejdziemy w głąb mechanizmu budowania konfiguracji na platformie .NET. Pozwoli nam to na zrozumieć jego działanie tak, aby w prosty sposób stworzyć własne rozszerzenie konfiguracji pozwalające nam na integrację z serwisami zewnętrznymi, które nie dostarczają oficjalnego SKD. Zrobimy to na przykładzie usługi AWS AppConfig.

Jak Działa konfiguracja w .NET

Zacznijmy od samego początku. Konfiguracja w aplikacjach opartych o platformę .NET posiada budowę warstwową. Kolejne warstwy nadpisują klucze / wartości tych zdefiniowanych wcześniej. Osobiście obrazuje to sobie następująco:

Najpierw definiujemy podstawę. Najczęściej są to informację znajdujące się w pliku appsettings.json. Później deklarujemy kolejne źródła naszej konfiguracji. Mogą one być różne i w zależności od naszych potrzeb będziemy ładowali informację z:

  • inny plików json
  • zmiennych środowiskowych
  • usług do przechowywania secret-ów / konfiguracji (jak np. Azure Key Vault lub AWS App Config)
  • sekretów użytkownika (warto zainteresować się tym tematem, ponieważ potrafi niesamowicie ułatwić lokalny development. Ze smutkiem muszę stwierdzić, że jest często niedoceniana przez programistów. Jest to rozległy temat, zasługujący na osobny artykuł )

Składnia do załadowania konfiguracji jest zazwyczaj bardzo prosta i wygląda najczęściej następująco:

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddJsonFile("appsettings.json", false, true) // -> pierwsze źródło, plik json
    .AddJsonFile("appsettings.secrets.json", true, true) // -> drugi źródło, opcjonalny plik json
    .AddEnvironmentVariables() // -> trzecie źródło, zmienne środowiskowe
    .AddUserSecrets<Program>(optional: true); // -> czwarte źródło, sekrety użytkownika

if (builder.Environment.IsProduction())
{
    builder.Configuration.AddAzureKeyVault(
        new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"),
        new DefaultAzureCredential()); // -> piąte źródło, usługa Azure KeyVault
}

Niestety usługa, z której chciałem skorzystać, nie ma w swoim sdk tak intuicyjnej metody na rejestrację danych (a przynajmniej na czas pisania tego artykułu ja nie znalazłem. Jeżeli coś się zmieniło albo sam / sama znasz lepszy sposób to proszę daj znać w komentarzach!)

Czym jest AWS AppConfig

Zanim przejdziemy do implementacji to słowem wstępu chciałbym przedstawić usługę AWS AppConfig. Jest to funkcjonalność większej usługi, która nazywa się AWS System Manager. Pozwala na zarządzanie i wdrażanie konfiguracji.

Jednym z jej ciekawszych plusów jest możliwość zdefiniowania walidacji przy pomocy JSON Schema lub przy wykorzystaniu usługi serverless AWS Lambda co już kilka razy uchroniło mnie przed wdrożeniem wadliwej konfiguracji.

Utworzenie Konfiguracji w AWS

Nie chcąc dodawać nowych narzędzi (czyżby szykował się również artykuł o wykorzystywaniu terraforma do tworzenia instancji App Config-a?) przejdziemy przez "manualne" utworzenie instancji App Config-a wraz z wdrożeniem naszej konfiguracji.

Po zalogowaniu się do konsoli AWS wystarczy odnaleźć App Config w wyszukiwarce znajdującej się w górnej części ekranu

Następnie, jeżeli nigdy nie tworzyłeś / tworzyłaś konfiguracji w danym projekcie, przed Twoimi oczami powinna pojawić się strona powitalna, z trochę obszerniejszym wyjaśnieniem usługi niż to co naskrobałem powyżej. Teraz wystarczy kliknąć w przycisk Get Started

Następnym krokiem będzie zdefiniowanie nazwy naszej aplikacji. W moim przypadku będzie to test-application.

Po kliknięciu przycisku Create Application zostaniemy przekierowani do ekranu zarządzania nowo utworzonym zasobem, gdzie możemy zarządzać profilami konfiguracji, środowiskami jak i wdrożeniami konfiguracji.

Teraz czas kliknąć Create. Aby przejść do definiowana konfiguracji musimy w dalszym etapie wybrać Freeform Configuration (drugą opcją są flagi funkcjonalności, niezwykle interesujący mechanizm szerzej opisany we wcześniejszych artykułach Gitlab - Feature Toggles, Feature Toggles – Permissioning Toggles)

Następnie przechodzimy do ekranu konfiguracyjnego (konfiguracja naszej konfiguracji, taka incepcja trochę 😂).

Aby nie rozpisywać przesadnie, wstawiłem powyżej screen konfiguracji potrzebnej do przejścia tego tutoriala. Omawiając najważniejsze podpunkty: nazwę ustawiłem na test-configuration i content na json. Poniżej konfiguracji typu treści jest także pole tekstowe, gdzie od razu można wkleić pierwszą wersję konfiguracji.

Ostatnim elementem jaki pozostał jeszcze do zrobienia jest wdrożenie konfiguracji. Z ekranu, na który zostaliśmy przekierowani, możemy przejść do wdrożenia za pomocą przycisku Start deployment. W tym momencie AWS zapyta nas na jakie środowisko chcemy wdrożyć konfigurację. Jeżeli nie masz utworzonego środowiska to możesz to tutaj zrobić za pomocą przycisku Create Environment

AWS poprosi nas o podanie nazwy środowiska i po utworzeniu go zostaniemy ponownie przekierowani na ekran formularza wdrażania. Przejście dalej wymaga wybrania strategii wdrażania. Na chwilę obecną możemy wybrać AppConfig.AllAtOnce. Zastosowaniami i obsługą pozostałych zajmiemy się w osobnym artykule (ilość zapowiedzi dopiero się rozkręca, zapowiem jeszcze nie jedno!).

Po kliknięciu przycisku Start deployment nasza konfiguracja powinna być gotowa w mgnieniu oka (chyba, że ktoś mruga niespiesznie, to po prostu uznajmy, że szybko).

Wiem, ten proces wygląda na całkiem długi, ale spokojnie można go zautomatyzować 😅

Implementacja Rozszerzenia Konfiguracji

Mając usługę AWS App Config gotową do użycia, możemy przejść do implementacji. W pierwszej kolejności tworzymy obiekt, którego zadaniem będzie przechowywanie informacji na temat nazwy aplikacji, nazwy środowiska i nazwy konfiguracji. Dane te będą nam później potrzebne dla klienta AWS-owego.

Wystarczy nam najprostszy rekord z stringami.

public record AppConfigOptions(string Application, string Environment, string Configuration);

Następnie przejdźmy do konfiguracji samego projektu. Chcąc łączyć się z AWS-em za pomocą oficjalnego SDK będziemy potrzebowali dograć kilka paczek nuget-a.

<PackageReference Include="AWSSDK.AppConfig" Version="3.7.102.6" />
<PackageReference Include="AWSSDK.AppConfigData" Version="3.7.101.6" />
<PackageReference Include="AWSSDK.Extensions.NETCore.Setup" Version="3.7.4" />

Oprócz nuget-ów związanych z usługą AppConfig znajdziemy też AWSSDK.Extensions.NETCore.Setup. Jest to paczka pomocnicza, która ładuje kilka elementów do naszego kontenera dependency injection i pobiera część danych z konfiguracji (takie jak np. region do którego będziemy chcieli się później odwoływać)

W ten sposób płynnie możemy przejść do konfiguracji aplikacji w pliku appsettings. Musimy tam utworzyć dwie sekcję. Pierwszą nazywającą się AWS, gdzie zadeklarujemy z jakiego profilu i do jakiego regionu chcemy się odwoływać. Oraz drugą, własną konfigurację potrzebną do pobrania danych z usługi AppConfig.

{
 "AWS": {
    "Profile": "Default",
    "Region": "eu-west-1"
  },
  "AppConfig": {
    "Application": "test-application",
    "Environment": "test",
    "Configuration": "test-configuration"
  },
  "secret": "no-secret-in-appsettings"
}

Implementacje zacznijmy od klasy, która będzie pobierać dane z AppConfig-a. Wiem, że w poniższym kodzie kością niezgody może się okazać nazwa tej klasy, ale to naprawdę bez większego znaczenia czy nazwiemy ją AppConfigService czy AppConfigClient. Dla mnie jest najważniejsze, aby ta klasa była blisko innych klas związanych z tą funkcjonalnością.

Co ważniejsze, chcąc pobrać konfigurację w pierwszej kolejności musimy utworzyć sesję w api App Config-a. Dopiero w późniejszym żądaniu możemy ściągnąć ostatnią konfigurację (lub konfigurację o żądanej wersji w zależności od potrzeb). Do uproszczenie parsowania odpowiedzi do naszego obiektu konfiguracyjnego wystarczy zwrócić strumień danych. Przy okazji kolejnego snippet-a wyjaśnię dlaczego tak będzie lepiej.

public class AppConfigService
{
    private readonly AppConfigOptions _options;
    private readonly AWSOptions _awsOptions;

    public AppConfigService(AppConfigOptions options, AWSOptions awsOptions)
    {
        _options = options;
        _awsOptions = awsOptions;
    }
    
    public async Task<MemoryStream> GetConfigurationResponse() {
        var amazonAppConfigData = _awsOptions.CreateServiceClient<IAmazonAppConfigData>();

        var startConfigurationSessionRequest = new StartConfigurationSessionRequest
        {
            ApplicationIdentifier = _options.Application,
            EnvironmentIdentifier = _options.Environment,
            ConfigurationProfileIdentifier = _options.Configuration,
            RequiredMinimumPollIntervalInSeconds = 60
        };

        var session = await amazonAppConfigData.StartConfigurationSessionAsync(startConfigurationSessionRequest);

        var getLatestConfigurationRequest = new GetLatestConfigurationRequest
        {
            ConfigurationToken = session.InitialConfigurationToken
        };

        var configurationResponse = await amazonAppConfigData.GetLatestConfigurationAsync(getLatestConfigurationRequest);
        
        return configurationResponse.Configuration;
    }
}

Kolejnym elementem będzie utworzenie provider-a do naszej konfiguracji. Przeparsowanie json-a do listy obiektów typu KeyValuePair z jednej strony wygląda prosto, jednak zachowanie odpowiedniego formatu wymaga już starań. Dlaczego akurat KeyValuePair? Spieszę z wyjaśnieniem. Pozwala nam to utrzymać jednolitość, gdyż tak są parsowane wszystkie wcześniej zadeklarowane konfiguracje.

Zwróćmy uwagę na specyficzne zapis klucza w przypadku obiektów: AWS:Region. To właśnie za pomocą : są obsługiwane zagnieżdżenia. Dlatego też w przypadku ładowania zmiennych środowiskowych jest używana _ jako zagnieżdzenie ponieważ zmienne środowiskowe nie mogą zawierać znaku :. Dopiero sam provider robi replace.

Postanowiłem skorzystać z provider-a, który już istnieje w środowisku JsonStreamConfigurationProvider. To właśnie z niego korzystamy przy okazji wywoływania metody AddJsonFile. Wykorzystanie bytu, który już istnieje w środowisku ma jeszcze jeden plus.Jeżeli z jakiegoś powodu zostałby zmieniony format danych, w którym jest przechowywana nasza konfiguracja, to nie będziemy zmuszeni do przepisywania naszego parser-a.

public class AppConfigConfigurationProvider : JsonStreamConfigurationProvider
{
    private readonly AppConfigOptions _options;
    private readonly AWSOptions _awsOptions;

    public AppConfigConfigurationProvider(AppConfigOptions options, AWSOptions awsOptions, JsonStreamConfigurationSource source) : base(source)
    {
        _options = options;
        _awsOptions = awsOptions;
    }
    
    public override void Load()
    {
        var appConfigService = new AppConfigService(_options, _awsOptions);
        var stream = appConfigService.GetConfigurationResponse().GetAwaiter().GetResult();

        base.Load(stream);
    }
}

Jako, że metoda Load przyjmuje strumień danych to korzystamy z niej bez problemów. Tutaj też na światło wychodzi pewne ograniczenie, z którym musimy się borykać. Metoda Load nie zwraca wartości (void). Jest zadeklarowana jako metoda synchroniczna dlatego też musimy się tutaj uciekać do GetAwaiter().GetResult(). Na szczęście będzie to ładowane tylko raz na starcie aplikacji więc przynajmniej przy obecnie opisywanym sposobie wykorzystania nie powinno to być problemem.

Pozostały już tylko dwa elementy do zdefiniowania. Pierwszym z nich jest klasa implementująca interfejs IConfigurationSource. To właśnie ona będzie punktem startowym dla całego mechanizmu konfiguracji.

Jej zadaniem będzie przyjęcie dwóch konfiguracji, obiektu AppConfigOptions, który sami zbudujemy za chwilę za pomocą extension method. Drugim parametrem konstruktora tej klasy będzie AWSOptions - obiekt zbudowany przez paczkę nuget: AWSSDK.Extensions.NETCore.Setup, zawierający takie informacje jak region i profil.

Natomiast jeżeli chodzi o odpowiedzialność tej klasy, to w metodzie Build musimy utworzyć nowy obiekt klasy AppConfigConfigurationProvider i go zwrócić.

public class AppConfigConfigurationSource : IConfigurationSource
{
    private readonly AppConfigOptions _options;
    private readonly AWSOptions _awsOptions;

    public AppConfigConfigurationSource(AppConfigOptions options, AWSOptions awsOptions)
    {
        _options = options;
        _awsOptions = awsOptions;
    }
    
    public IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        return new AppConfigConfigurationProvider(_options, _awsOptions, new JsonStreamConfigurationSource());
    }
}

Jeśli do teraz nie pominęliście żadnego z puzzli to przechodzimy do ostatniego elementu tej układanki, a mianowicie metody rozszerzającej interfejs IConfigurationBuilder. Skorzystamy z niej abyśmy mogli korzystać z konfiguracji znajdującej się w AWS App Config tak samo łatwo (a nawet i łatwiej) niż z Azure KeyValue. Wpierw musimy, tymczasowo, zbudować naszą konfigurację. Następnie, na podstawie pobranych danych, trzeba zainicjalizować nasz obiekt AppConfigOptions iprzy pomocy metody Add dodać nową instancję AppConfigConfigurationSource do naszej listy źródeł konfiguracji.

public static class AppConfigConfigurationExtensions
{
    public static IConfigurationBuilder AddAwsAppConfig(this IConfigurationBuilder configuration)
    {
        var tempConfig = configuration.Build();
        var awsOptions = tempConfig.GetAWSOptions();
        var application = tempConfig.GetValue<string>("AppConfig:Application");
        var environment = tempConfig.GetValue<string>("AppConfig:Environment");
        var config = tempConfig.GetValue<string>("AppConfig:Configuration");
        var appConfigOptions = new AppConfigOptions(application!, environment!, config!);

        return configuration.Add(new AppConfigConfigurationSource(appConfigOptions, awsOptions));
    }
}

Oczywiście powoduje to też coupling pomiędzy tym co znajduje się w appsettings.json a naszym AddAwsAppConfig, ponieważ, jeżeli nasza metoda zostałaby zadeklarowana wcześniej, to nie bylibyśmy w stanie pobrać konfiguracji.

Na koniec możemy zobaczyć jak będzie wyglądać wykorzystanie tego źródła konfiguracji w pliku Program.cs

builder.Configuration.AddJsonFile("appsettings.json", false, true)
    .AddJsonFile("appsettings.secrets.json", true, true)
    .AddEnvironmentVariables()
    .AddAwsAppConfig()
    .AddUserSecrets<Program>(optional: true);

Słowo końcowe

Prawdę mówiąc, zaczynając ten artykuł, nie spodziewałem się jego rozmiarów. Oczywiście rozumiałem rozległość zagadnienia i byłem pewien, że do top 5 najkrótszych nie trafi. Mam nadzieje mój drogi czytelniku, że z jego treści dowiedziałeś się czegoś nowego, inspirującego, przydatnego lub chociaż dotrwałeś do końca bez złowieszczych myśli w moim kierunku. Natomiast, jeżeli masz propozycje tematów o jakich chciałbyś / chciałabyś poczytać, to zachęcamy do skorzystania z zakładki Kontakt lub do pozostawienia komentarza pod tym artykułem.

Do Następnego!

Cześć

By Bd90 | 10-03-2023 | .NET, AWS