简体   繁体   中英

How to save/load a List<string> variable from PropertyGrid to an ini settings file?

I'm working on a Window Forms application in Visual Studio, and I'm using a custom settings object to keep track of some application settings. The user can change these settings through the PropertyGrid widget.

This works great for string and integer values, but now I also want to add a List<string> variable, so the user can enter a list of keywords.

I've added the List<string> variable to the settings object and I've added a TypeConverter to show it as a comma separated string representation in the PropertyGrid. Without the TypeConverter the value would display as just (Collection) . It is displayed correctly and I can edit it, see screenshot below

this._MyProps = new PropsClass();
this._MyProps.ReadFromIniFile("mysettings.ini");
propertyGrid1.SelectedObject = this._MyProps;

带有 C# 中的 PropertyGrid 示例的 Visual Studio Windows 窗体应用程序

Now I also want to write and read these setting to a settings.ini file, so I've added SaveToIniFile and ReadFromIniFile methods to the object. This works for string and integer values, except the List<string> is not saved and loaded to and from the.ini file correctly. When I call SaveToIniFile the content mysettings.ini is for example this, still using the "(Collection)" representation and not the values entered by the user:

[DataConvert]
KeyWordNull=NaN
ReplaceItemsList=(Collection)
YearMaximum=2030

So my question is, how can I save/load a List<string> setting to an ini file while also allowing the user to edit it in a PropertyGrid?

I know it'd have to convert from a string to a List somehow, maybe using quotes around the string to inclkude the line breaks, or maybe just comma-separated back to a list of values? But anyway I thought that is what the TypeConverter was for. So why is it showing correctly in he PropertyGrid but not in the ini file? See code below

The custom settings properties object:

// MyProps.cs
public class PropsClass
{
    [Description("Maximum year value."), Category("DataConvert"), DefaultValue(2050)]
    public int YearMaximum { get; set; }

    [Description("Null keyword, for example NaN or NULL, case sensitive."), Category("DataConvert"), DefaultValue("NULL")]
    public string KeyWordNull { get; set; }

    private List<string> _replaceItems = new List<string>();

    [Description("List of items to replace."), Category("DataConvert"), DefaultValue("enter keywords here")]
    [Editor("System.Windows.Forms.Design.StringCollectionEditor, System.Design, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(UITypeEditor))]
    [DesignerSerializationVisibility(DesignerSerializationVisibility.Content)]
    [TypeConverter(typeof(StringListConverter))]
    public List<string> ReplaceItemsList
    {
        get
        {
            return _replaceItems;
        }
        set
        {
            _replaceItems = value;
        }
    }

and in the same PropsClass class, the write and read methods to save/load from a settings.ini file

    [DllImport("kernel32.dll")]
    public static extern int GetPrivateProfileSection(string lpAppName, byte[] lpszReturnBuffer, int nSize, string lpFileName);

    public void SaveToIniFile(string filename)
    {
        // write to ini file
        using (var fp = new StreamWriter(filename, false, Encoding.UTF8))
        {
            // for each different section
            foreach (var section in GetType()
                .GetProperties()
                .GroupBy(x => ((CategoryAttribute)x.GetCustomAttributes(typeof(CategoryAttribute), false)
                                    .FirstOrDefault())?.Category ?? "General"))
            {
                fp.WriteLine(Environment.NewLine + "[{0}]", section.Key);
                foreach (var propertyInfo in section.OrderBy(x => x.Name))
                {
                    var converter = TypeDescriptor.GetConverter(propertyInfo.PropertyType);
                    fp.WriteLine("{0}={1}", propertyInfo.Name, converter.ConvertToInvariantString(propertyInfo.GetValue(this, null)));
                }
            }
        }
    }

    public void ReadFromIniFile(string filename)
    {
        // Load all sections from file
        var loaded = GetType().GetProperties()
            .Select(x => ((CategoryAttribute)x.GetCustomAttributes(typeof(CategoryAttribute), false).FirstOrDefault())?.Category ?? "General")
            .Distinct()
            .ToDictionary(section => section, section => GetKeys(filename, section));

        //var loaded = GetKeys(filename, "General");
        foreach (var propertyInfo in GetType().GetProperties())
        {
            var category = ((CategoryAttribute)propertyInfo.GetCustomAttributes(typeof(CategoryAttribute), false).FirstOrDefault())?.Category ?? "General";
            var name = propertyInfo.Name;
            if (loaded.ContainsKey(category) && loaded[category].ContainsKey(name) && !string.IsNullOrEmpty(loaded[category][name]))
            {
                var rawString = loaded[category][name];
                var converter = TypeDescriptor.GetConverter(propertyInfo.PropertyType);
                if (converter.IsValid(rawString))
                {
                    propertyInfo.SetValue(this, converter.ConvertFromString(rawString), null);
                }
            }
        }
    }

    // helper function
    private Dictionary<string, string> GetKeys(string iniFile, string category)
    {
        var buffer = new byte[8 * 1024];

        GetPrivateProfileSection(category, buffer, buffer.Length, iniFile);
        var tmp = Encoding.UTF8.GetString(buffer).Trim('\0').Split('\0');
        return tmp.Select(x => x.Split(new[] { '=' }, 2))
            .Where(x => x.Length == 2)
            .ToDictionary(x => x[0], x => x[1]);
    }
}

and the TypeConverter class for the ReplaceItemsList property

public class StringListConverter : TypeConverter
{
    public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
    {
        if (value is List<string>)
        {
            return string.Join(",", ((List<string>)value).Select(x => x));
        }
        return base.ConvertTo(context, culture, value, destinationType);
    }
}

If you are going to use a custom TypeConverter , you'll have to register it as a provider to TypeDescriptionProvider :

TypeDescriptor.AddProvider(new CustumTypeDescriptorProvider(), typeof(List<string>));

And in your implementation you could just do this in the constructor of PropsClass (instead of using the attribute). I created some custom code below that would do the split.

public class PropsClass
{

    [DllImport("kernel32.dll")]
    public static extern int GetPrivateProfileSection(string lpAppName, byte[] lpszReturnBuffer, int nSize, string lpFileName);

    [Description("Maximum year value."), Category("DataConvert"), DefaultValue(2050)]
    public int YearMaximum { get; set; }

    [Description("Null keyword, for example NaN or NULL, case sensitive."), Category("DataConvert"), DefaultValue("NULL")]
    public string KeyWordNull { get; set; }

    private List<string> _replaceItems = new List<string>();

    [Description("List of items to replace."), Category("DataConvert"), DefaultValue("enter keywords here")]
    [Editor("System.Windows.Forms.Design.StringCollectionEditor, System.Design, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(UITypeEditor))]
    [DesignerSerializationVisibility(DesignerSerializationVisibility.Content)]
    public List<string> ReplaceItemsList
    {
        get
        {
            return _replaceItems;
        }
        set
        {
            _replaceItems = value;
        }
    }

    public PropsClass()
    {
        TypeDescriptor.AddProvider(new CustumTypeDescriptorProvider(), typeof(List<string>));
    }

    public void SaveToIniFile(string filename)
    {
        // write to ini file
        using (var fp = new StreamWriter(filename, false, Encoding.UTF8))
        {
            // for each different section
            foreach (var section in GetType()
                .GetProperties()
                .GroupBy(x => ((CategoryAttribute)x.GetCustomAttributes(typeof(CategoryAttribute), false)
                                    .FirstOrDefault())?.Category ?? "General"))
            {
                fp.WriteLine(Environment.NewLine + "[{0}]", section.Key);
                foreach (var propertyInfo in section.OrderBy(x => x.Name))
                {
                    var converter = TypeDescriptor.GetConverter(propertyInfo.PropertyType);
                    fp.WriteLine("{0}={1}", propertyInfo.Name, converter.ConvertToInvariantString(propertyInfo.GetValue(this, null)));
                }
            }
        }
    }

    public void ReadFromIniFile(string filename)
    {
        // Load all sections from file
        var loaded = GetType().GetProperties()
            .Select(x => ((CategoryAttribute)x.GetCustomAttributes(typeof(CategoryAttribute), false).FirstOrDefault())?.Category ?? "General")
            .Distinct()
            .ToDictionary(section => section, section => GetKeys(filename, section));

        //var loaded = GetKeys(filename, "General");
        foreach (var propertyInfo in GetType().GetProperties())
        {
            var category = ((CategoryAttribute)propertyInfo.GetCustomAttributes(typeof(CategoryAttribute), false).FirstOrDefault())?.Category ?? "General";
            var name = propertyInfo.Name;
            if (loaded.ContainsKey(category) && loaded[category].ContainsKey(name) && !string.IsNullOrEmpty(loaded[category][name]))
            {
                var rawString = loaded[category][name];                
                var converter = TypeDescriptor.GetConverter(propertyInfo.PropertyType);
                if (converter.IsValid(rawString))
                {
                    propertyInfo.SetValue(this, converter.ConvertFromString(rawString), null);
                }
            }
        }
    }

    // helper function
    private Dictionary<string, string> GetKeys(string iniFile, string category)
    {
        var buffer = new byte[8 * 1024];

        GetPrivateProfileSection(category, buffer, buffer.Length, iniFile);
        var tmp = Encoding.UTF8.GetString(buffer).Trim('\0').Split('\0');
        return tmp.Select(x => x.Split(new[] { '=' }, 2))
            .Where(x => x.Length == 2)
            .ToDictionary(x => x[0], x => x[1]);
    }
}

public class CustumTypeDescriptorProvider : TypeDescriptionProvider
{
    public override ICustomTypeDescriptor GetTypeDescriptor(System.Type objectType, object instance)
    {
        if (objectType.Name == "List`1") return new StringListDescriptor();
        return base.GetTypeDescriptor(objectType, instance);
    }
}
public class StringListDescriptor : CustomTypeDescriptor
{
    public override TypeConverter GetConverter()
    {
        return new StringListConverter();
    }
}

public class StringListConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        if (sourceType == typeof(string))
        {
            return true;
        }

        return base.CanConvertFrom(context, sourceType);
    }

    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        string s = value as string;

        if (!string.IsNullOrEmpty(s))
        {
            return ((string)value).Split(',').ToList();
        }

        return base.ConvertFrom(context, culture, value);
    }

    public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
    {
        if (destinationType == typeof(string))
        {
            return string.Join(",", (List<string>)value);
        }

        return base.ConvertTo(context, culture, value, destinationType);
    }
}

*NOTE: In my testing, the method ConvertFrom is called twice, once from converter.IsValid and once from propertyInfo.SetValue .

*NOTE2: You are using streamwriter to update the ini file. Since you are using GetPrivateProfileSection , it seems you should be using WritePrivateProfileSection to update the ini file.

*NOTE3: please consider the original comments in question about whether you should be using this method to read/write to an ini file. These methods have been around a long time. ( https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getprivateprofilesection )

*NOTE4: A lot of this answer came from: Convert string to Array of string using TypeConverter

Leaving apart the PropertyGrid part which is just a way to put the info in a cartesian way on a Windows Form, the tricky part as I understand it, is to force a ini file (which is typically a 1:1 correlation between string s and string s) to house a collection of right hand side (rhs) values. Obviously, they cannot reside of a left hand side (lhs) of the ini line. You could, but then likely the choice of a ini file to persist your data is wrong at the roots.

So, why not avoiding to reinvent the wheel and go straight to custom parsing? This way you can also choose the separator to use (you may like to mimic windows, or apache setting files, etc).

Look at this implementation of one old code of mine. Grossly, it is an ini file telling my app which web exchange to contact for crypto trading. Before you get into a big headache with names: Monday is the name of the library holding the common code, and Friday is an app using Monday for trading online with a specific algo. Nothing important for the case being.

The ini file looks like this:

[Friday]

; if true the permutations will be only defined starting from the currency provided with index 0 in [Friday-Currencies]
MaxChainTransactions = 7

; if UseLastTradeValues is true, it will override UseConservativeBidAskValues
UseLastTradeValues = true

; if true the matrix values are (Ask+Bid)/2. This flag overrides UseConservativeBidAskValues but gets overridden by UseLastTradeValues
UseMidPriceValues = false

; if true the most conservative value between bid and ask will be used to calculate gains. Used only if UseLastTradeValues is false
UseConservativeBidAskValues = true

; if true, the solver will also solve with a matrix that will contain the best trade values from the available exchanges in parallel to other exchanges individually
MergeExchanges = false

; If true, Friday will issue one solution (if any) for each exchange, comprising one for the merged market if calculated.
DeliverOneSolutionPerExchange = true

; use values from the provided exchanges only
FridayExchanges = Bittrex| Binance | Kraken

; the minimum allowed gain remaining at the closure of the transaction chain to approve the full chain for ordering

MimimumAllowedGain = 1.05
; this is the target gain of a transaction. it is not used in case AllowPriceCorrectionBelowTargetGain is false

TargetGain = 1.09
; if true the prices in the transaction will be modified (increased towards the aggressive trade) of a factor equal to MaxPriceCorrectionFactor
AllowPriceCorrectionBelowTargetGain = true

; the frequency of each solution cycle (provided that the previous cycle has already completed and new data is arrived)
SolveEveryNSeconds = 15

Look at the line:

FridayExchanges = Bittrex| Binance | Kraken

it receives more than one exchange as a string separated by a pipe (|). You may choose other separators.

I suppose you parse the ini file with the support of some third party code. I used ini-parser ( https://github.com/rickyah/ini-parser ) that helped me a lot.

Parsing is done this way:

private static (int, string[]) ReadFridayExchanges()
        {          
            string? configEntry = MondayConfiguration.MondaySettings["Friday"]["FridayExchanges"];
            List<string> fridayExchanges = configEntry.ParseExchangeKey();
            return (fridayExchanges.Count, fridayExchanges.ToArray());
        }

Leave apart all the variables and names, the interesting one is MondaySettings . It is defined like this:

using IniParser;
using IniParser.Model;
/// <summary>
///     Holds the dictionary of Settings in IniData format.
/// </summary>
public static IniData MondaySettings { get; private set; }

and initialized like this:

[MemberNotNull(nameof(MondaySettings))]
private static void ReadSettingsFile(string settingsFile)
{
    var iniParser = new FileIniDataParser();
    MondaySettings = iniParser.ReadFile(settingsFile);

    if (!DateTime.TryParse(MondaySettings["General"]["LastShutDown"], out _))
    {
        CorrectLastShutDownTime();
    }
}

you find all nice calls in the ini-parser package to read and write the ini files automatically with almost one-liners.

When saving the file before closing and exiting:

private static bool SaveInternalSettings(string settingsFilename)
{
    string settingsFile = Path.Combine(SettingsLocationFullPath, settingsFilename);
    MondaySettings["General"]["LastShutDown"] = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture);
    File.Delete(settingsFile);
    try
    {
        var parser = new FileIniDataParser();
        parser.WriteFile(settingsFile, MondaySettings);
        return true;
    }
    catch
    {
return false;
}

}

Now the interesting part: parsing multiple rhs values within ini . My solution was to do it manually, which is also one of the fastest ways.

        /// <summary>
        ///     Reading Settings.ini for multi value lines, this routine parses the right hand side
        ///     of each records to define the list of values coupling it with the required
        ///     exchanges. Returns an array of exchanges that may be contained in a complex key to
        ///     resolve the All case and the presence of the pipe symbol (|).
        ///     NOTE: it makes use of the indexes for markets, therefore those structures should be
        ///     ready before this call to the method.
        /// </summary>
        /// <param name="rhrMarket">
        ///     The original key as written in the .ini file.
        /// </param>
        /// <returns>
        ///     A list of strings representing all the exchanges in a single record of
        ///     InterestingMarkets section the settings. If only one is contained in the original
        ///     key, a single element is contained.
        /// </returns>
        public static List<string> ParseExchangeKey(this string rhrMarket)
        {
            List<string> answer = new();
            if (string.Equals(rhrMarket, "all", StringComparison.OrdinalIgnoreCase))
            {
                // producing all known exchanges as answer
                foreach (KeyValuePair<string, int> ex in Tags.Exchanges.ActiveExchanges)
                {
                    if (ex.Value != 0) // to exclude Monday which is id=0
                    {
                        answer.Add(ex.Key);
                    }
                }
            }
            else if (rhrMarket.Contains('|'))
            {
                // sorting multiple cases
                string[] split = rhrMarket.Split('|');
                foreach (string? subElement in split)
                {
                    answer.Add(subElement.ToLower().Trim());
                }
            }
            else
            {
                answer.Add(rhrMarket.ToLower());
            }

            return answer;
        }

There is also some XML to help you get to the point. As you can see, ParseExchangeKey returns a list of string s.

Just to make sure you have all the elements to get the Ienumerables, this is the definition of Tags.Exchanges.ActiveExchanges :

            /// <summary>
            ///     Contains the set of active exchanges included into [ActiveExchanges] section in
            ///     settings.ini and having proper credentials to activate the web account. The
            ///     Values of the dictionary are the ID in _allExchanges. Monday is not included.
            /// </summary>
            [PublicAPI]
            public static Dictionary<string, int> ActiveExchanges => _b_activeExchanges_;

            /// <summary>
            ///     IMPL: This is internal because it needs to be set by <see cref="MondayConfiguration"/>.
            ///     it does not contain Monday's entry.
            /// </summary>
            private static readonly Dictionary<string, int> _b_activeExchanges_ = LoadExchangesDictionary();

 private static Dictionary<string, int> LoadExchangesDictionary()
            {
                Dictionary<string, int> answer = new();
                // set it internally without using the property which blocks the caller up the
                // closure of the dictionary.
                var counter = 0;
                foreach (KeyValuePair<string, int> item in _allExchanges_)
                {
                    if (item.Value != 0 && MondayConfiguration.Credentials.Any(c => c.Exchange == item.Key))
                    {
                        answer.Add(item.Key.ToLower(), item.Value);
                        counter++;
                    }
                }

Saving is the same, however ini-parser leaves the original string with separators (pipes in this case) in its memory, if your used modified the set of multi-values, you just need to provide a simple ToString() version to concatenate it back before saving the file using the accustomed separator.

Then coupling with the controls of the Windows Forms is easy when you can get the List<string> object to move around.

This way you may skip all the custom classes for converting the type around which will likely slow down maintenance when you need to add or remove records from the ini file.

The reason your type converter is not used is because of this line:

var converter = TypeDescriptor.GetConverter(propertyInfo.PropertyType);

You are getting the TypeConverter that is defined on the type of the property. So for ReplaceItemsList that would be the TypeConverter for List<T> . You need to get the TypeConverter for the property since that is where you added the TypeConverter attribute. So either you do something like you did for the category attribute in the read method where you use the PropertyInfo's GetCustomAttributes or you do what the PropertyGrid does which is use the PropertyDescriptor s to get to the properties and their state. The latter would be better since if the object implemented ICustomTypeDescriptor or some other type augmentation like TypeDescriptionProvider then you would get that automatically.

So something like the following for the Save using PropertyDescriptors would be:

public void SaveToIniFile(string filename)
{
    // write to ini file
    using (var fp = new StreamWriter(filename, false, Encoding.UTF8))
    {
        // for each different section
        foreach (var section in TypeDescriptor.GetProperties(this)
            .Cast<PropertyDescriptor>()
            .GroupBy(x => x.Attributes.Cast<Attribute>().OfType<CategoryAttribute>()
                .FirstOrDefault()?.Category ?? "General"))
        {
            fp.WriteLine(Environment.NewLine + "[{0}]", section.Key);
            foreach (var propertyInfo in section.OrderBy(x => x.Name))
            {
                var converter = propertyInfo.Converter;
                fp.WriteLine("{0}={1}", propertyInfo.Name, converter.ConvertToInvariantString(propertyInfo.GetValue(this)));
            }
        }
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM