Dynamische content in interpolated strings

Ingediend door Dirk Hornstra op 18-jan-2019 21:49

Zelf gebruikte ik dit nog niet, bij een stagiair zag ik dit in de code staan. "Vroeger" had je dat je strings aan elkaar plakte:


string fileLocationRemote = @"\\" + Environment.SpecialFolder.ApplicationData + @"\locatie\bestand.txt";

Is dat netjes? Nee, niet echt. Daarna kreeg je de string.Format-functie die ik wel redelijk vaak gebruik:


string fileLocationRemote = string.Format(@"\\{0}\locatie\bestand.txt", Environment.SpecialFolder.ApplicationData);

Dat is al beter. Ergens op een blog zag ik iemand die daarbij noemt dat je zelf de argumenten bij moet houden. Is eigenlijk ook zo, want als je 20 variabelen meegeeft, wat was ook alweer {18} en {19} ?

In C# 6 hebben we nu dus interpolated strings (al vanaf 2015?), volgens het item op StackOverflow (link) en dit artikel op geekswithblogs (link). Alleen heeft onze stagiaire nu een waarde die kan verschillen. De ene gebruikt hij om op een remote locatie te testen, de andere gebruikt hij voor de lokale settings, hierbij een niet exacte overname van de waarden, maar wel even het inzicht wat de verschillen zijn:


            string fileLocationLocal = $@"\\{Environment.SpecialFolder.ApplicationData}\locatie\bestand.txt"; // dit is eigenlijk remote
            string fileLocationLocal = $@"\\{Environment.SpecialFolder.CDBurning}\locatie\bestand.txt";

Compilen we het voor omgeving 1 dan kwam het ene in commentaar, compilen we voor de andere omgeving het andere in commentaar. Dat is natuurlijk niet de manier. De mooiste manier zou zijn dat je deze waarde in een config-bestand kunt zetten en dat kunt laden. Maar helaas, een snelle test daarmee lijkt niet te werken. Nu dus maar in het config-bestand in de appsettings een variabele ingesteld waarmee je kunt aangeven dat je "lokaal" of "remote" draait en daar een if / else controle in de code op gemaakt.

Dat werkt. Maar eigenlijk is het nog steeds niet "mooi". Want stel dat je 50 verschillende omgevingen zou hebben met verschillende configuraties dan zou je 50 if / else -statements krijgen. 

Na wat Google-zoekacties vind ik voorbeelden op Stackoverflow (link) en (link) en Microsoft (link) en (link). Het lukt me om een Helper-class te maken en met een simpele tekst werkt het. Met deze waarde echter (nog) niet, omdat je dan ook Environment-variabelen nodig hebt, waarschijnlijk zal ik ook de DLL's mee moeten geven.


// losse class

using Microsoft.CSharp;
using System;
using System.CodeDom.Compiler;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ConsoleTester
{
    public static class DynamicCompileHelper
    {
        public static CompilerResults CompileSource(string sourceCode)
        {
            var csc = new CSharpCodeProvider(
                new Dictionary<string, string>() { { "CompilerVersion", "v4.0" } });

            var referencedAssemblies =
                    AppDomain.CurrentDomain.GetAssemblies()
                    .Where(a => !a.FullName.StartsWith("mscorlib", StringComparison.InvariantCultureIgnoreCase))
                    .Where(a => !a.IsDynamic) //necessary because a dynamic assembly will throw and exception when calling a.Location
                    .Select(a => a.Location)
                    .ToArray();

            var parameters = new CompilerParameters(
                referencedAssemblies);

            return csc.CompileAssemblyFromSource(parameters,
                sourceCode);
        }

        public static object TryLoadCompiledType(this CompilerResults compilerResults, string typeName, out Type resultType, params object[] constructorArgs)
        {
            resultType = null;

            if (compilerResults.Errors.HasErrors)
            {
                Console.WriteLine("Can not TryLoadCompiledType because CompilerResults.HasErrors");
                return null;
            }

            var type = compilerResults.CompiledAssembly.GetType(typeName);

            if (null == type)
            {
                Console.WriteLine("Compiled Assembly does not contain a type [" + typeName + "]");
                return null;
            }

            resultType = type;
            return Activator.CreateInstance(type, constructorArgs);
        }

    }
}
 

// aanroepende code
            string fileLocationLocal = ConfigurationManager.AppSettings["fileLocation"];
            fileLocationLocal = "even een normale tekst";
            Type type = null;
            dynamic instance =
                    DynamicCompileHelper.CompileSource("using System; using System.Text; namespace ConsoleTester{public class DynamicCompile { public DynamicCompile(){} public string outputData(){ return \"" + fileLocationLocal+"\";} }}")
                    .TryLoadCompiledType("ConsoleTester.DynamicCompile", out type);

               MethodInfo method = type.GetMethod("outputData");
               string data = (string)method.Invoke(instance, null);
               Console.WriteLine(data);
 

Ik stop hiermee, want hoewel het misschien wel gaat werken, is het overkill (compileer code om een setting te configureren) en het is de vraag of het de juiste waarde teruggeeft (in wat voor context draait die code), dus we kunnen beter wachten wat Microsoft hier zelf gaat uitvoeren. 

Dan gaan we kijken naar een alternatief. Als ik de waarde in de appsettings van web.config en app.config plaats, dan ga ik deze niet verwerken met die interpolated strings, maar met een string.Format. Want als we er vanuit gaan dat alle te gebruiken waardes als "{... iets ertussen ..}" gedefinieerd wordt, en dat "iets ertussenin" bestaat alleen uit letters, mogelijk cijfers en punten. dan kan ik deze zo definiëren (ik gebruik één van de waardes die we echt gebruiken):


<add key="fileLocation" value="\\{System.Environment.MachineName}\web\interfaces\{username}" />

En dan kan ik deze redelijk makkelijk met een Regex eruit filteren. Zou je in de code alleen Environment.MachineName gebruiken, je moet nu de volledige namespace opgeven, zodat we een generieke functie kunnen maken om class en methode/velden eruit te filteren. Dat deel gaat wel lukken. De moeilijkheid ligt hier in de username-variabele. Als we ergens een functie gaan maken om je configuratie-waarde te parsen, daar heb je waarschijnlijk deze variabele niet beschikbaar (als het een lokale variabele binnen jouw functie zou zijn). Om dan je variabelen maar public op class-niveau te maken, dat is natuurlijk niet de bedoeling. Je hebt wel een nameof(..) functie waarmee je de naam kunt bepalen. Zien wat we kunnen doen...

Het heeft flink wat zweetdruppels gekost, maar ik heb een werkende oplossing. En ook nog eentje die er toch wel netjes uit ziet. Even de credits naar de pagina's die me op weg geholpen hebben: StackOverflow, de naam van een variabele opvragen: link, StackOverflow, hoe zit het ook alweer met de foreach en het wijzigen van waarden tijdens het loopen: link

 

We beginnen met een nieuw .cs bestand in C# en vullen deze met de onderstaande inhoud:


 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;

namespace ConfigurationFileSettingsParsing
{
    public static class ConfigurationFileSettingValueParser
    {
        /// <summary>
        /// process the input-command to fill the command with parameters {0}..{1}
        /// and place all values of the input {var1}..{var2} in the parameters list
        /// </summary>
        /// <param name="input"></param>
        /// <returns></returns>
        public static ConfigurationSettingSplit ParseToCommandAndParameters(this string input)
        {
            List<string> parameters = new List<string>();
            MatchCollection matchCollection = Regex.Matches(input, @"\{([a-z|.0-9]+)\}", RegexOptions.IgnoreCase);
            for (int k = 0; k < matchCollection.Count; k++)
            {
                string match = matchCollection[k].Value;
                input = input.Replace(match, $@"{{{k}}}");
                parameters.Add(match.ToString().TrimStart(new char[] { '{' }).TrimEnd(new char[] { '}' }));
            }

            return new ConfigurationSettingSplit() { Command = input, Parameters = parameters.ToArray() };
        }

        /// <summary>
        /// input is a local variable as class, result is a KeyValuePair with the name of the local variable and its value
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="item"></param>
        /// <returns></returns>
        public static KeyValuePair<string, string>? GetLocalVariableNameAndValue<T>(this T item) where T : class
        {
            if (item == null)
                return null;
            return new KeyValuePair<string, string>(typeof(T).GetProperties()[0].Name, item.ToString().TrimStart('{').TrimEnd('}').Split('=')[1].Trim());
        }

    }

    public class ConfigurationSettingSplit
    {
        public string Command { get; set; }
        public string[] Parameters { get; set; }
        public void ProcessParameters(params KeyValuePair<string, string>?[] localVariables)
        {
            Parameters.ToList().ForEach(p => Parameters[Parameters.ToList().FindIndex(x => x == p)] = TextToVariabeleValue(p, localVariables));
        }

        private string TextToVariabeleValue(string item, params KeyValuePair<string, string>?[] localVariables)
        {
            string result = "";
            string command = item;
                                  
            // if we are using a systemfunction, the "command" has at least 2 dots
            if (command.IndexOf('.') > 0 && command.IndexOf('.') != command.LastIndexOf('.'))
            {
                string classWithNameSpace = command.Substring(0, command.IndexOf('.', command.IndexOf('.') + 1));
                string functionToExecuteOrEnumType = command.Substring(classWithNameSpace.Length + 1);
                string enumValue = "";
                Type systemType = Type.GetType(classWithNameSpace);

                int dotIndex = functionToExecuteOrEnumType.IndexOf('.');
                if (dotIndex > 0)
                {
                    enumValue = functionToExecuteOrEnumType.Substring(dotIndex + 1);
                    functionToExecuteOrEnumType = functionToExecuteOrEnumType.Substring(0, dotIndex);
                }
                MemberInfo[] memberOfClass = systemType.GetMember(functionToExecuteOrEnumType);
                if (memberOfClass.Count() == 1)
                {
                    var enumResult = systemType.InvokeMember(functionToExecuteOrEnumType, BindingFlags.GetProperty, null, null, new object[] { });
                    result = enumResult.ToString();
                }
                else
                {
                    throw new NotImplementedException();
                }

            }
            else
            {
                result = localVariables.Where(rec => rec.HasValue && rec.Value.Key == command).Single().Value.Value;
            }
            return result;
        }
    }

}
 

Dit  zijn extensions voor bepaalde "string"-variabelen (zoals je kunt zien aan de "this" waarde bij het meegeven van de variabele. Doordat de slimme dingen hierboven gebeuren, blijft je uiteindelijke aanroepende code behoorlijk "clean":


using ConfigurationFileSettingsParsing;
// ....

 

        static void ShortCodeParseConfigurationValue()
        {
            try
            {
                string username = "dirk hornstra";
                int dummy = 1337;

                ConfigurationSettingSplit commandAndParameters = ConfigurationManager.AppSettings["fileLocation"].ParseToCommandAndParameters();
                commandAndParameters.ProcessParameters(
                    new { username }.GetLocalVariableNameAndValue(), 
                    new { dummy }.GetLocalVariableNameAndValue()
                );
                string location = string.Format(commandAndParameters.Command, commandAndParameters.Parameters);
            }
            catch (Exception x)
            {
                Console.WriteLine(x.ToString());
            }
        }

Het zijn maar 3 regels code. Het enige waar je voor moet zorgen is dat je alle mogelijke variabelen die in de configuratiewaarde kunnen staan moet meegeven.