Эта статья может не представлять особого интереса для тех, кто пользуется пакетом Microsoft.Extensions.Configuration. И описывает создание небольшого класса для сохранения собственных свойств этого класса в INI-подобный формат.

Наверняка многие разработчики сталкивались с необходимостью сохранения настроек своих приложений в файл и использовали для достижения этой цели различные сериализаторы типа XMLSerializer, JsonSerializer или BinaryFormatter. Однако, готовые решения не всегда так хороши, как это поначалу кажется. Сам я начинал с бинарных способов, но прочувствовав их неудобство перешёл на XML. Наигравшись с тормозами и проблемами XMLSerializer, заодно разочаровался и в самом XML. Наверняка многие замечали, что ручная правка XML файла с настройками не очень удобна, особенно если ваше приложение будете использовать не только вы, но и другие пользователи.

Пробовал и другие способы, но в итоге - глюки и проблемы, которые нет возможности устранить в этих внешних зависимостях, привели к решению сделать уже удобный для себя велосипед.

Хотелось простого и незамысловатого решения с минимальной длиной кода, в котором были бы методы у объекта, которые могли бы перебрать свойства самого этого объекта и сохранить или загрузить их.

Требования:

  • Сохранение данных в текстовый формат, который удобно редактировать в любом блокноте

  • Максимально простой код, размещённый в самом классе с настройками

  • Высокая скорость работы

  • Без зависимости от внешних компонентов

В итоге, после ряда итераций, пришёл к сериализации в плоский одноуровневый формат, похожий на INI файл, но без его ограничений, и в кодировке UTF-8.

Для преобразования объектов различных типов в строки сериализатор будет просто вызывать метод ToString для публичных свойств своего же объекта, а десериализатор будет вызывать Parse(string).

Таким образом, сериализовать себя смогут любые объекты, обладающие методами сохранения состояния через ToString и его восстановления через Parse. Например базовые типы. Для собственных объектов придётся добавить Parse и перегрузить ToString. Классы или структуры фреймворка можно расширить через наследование, или сделать обёртки, если наследование невозможно или не подходит по иным причинам. Ниже приведу примеры.

Возникает вопрос - почему бы не сериализовать вложенные объекты сложных типов рекурсивно, также перебирая их свойства? Одна из причин в том, что не понятно, как их сериализовать. Свойства далеко не всегда представляют истинное состояние объекта, он может возвращать и получать часть состояния через методы, перечислители (как например List), поля и так далее... В то же время далеко не все свойства бывают нужны для сериализации, и будут лишь забивать мусором файл.

Другая причина - хотелось бы получать на выходе человекочитаемый текстовый файл с настройками приложения, чтобы можно было править его вручную, а не нечитаемые пирамиды тегов XML или огороды из скобок как в JSON.

Итак, предположим, у нас имеется класс для хранения настроек приложения:

public class Settings
{
    public bool TopMost { get; set; } = false;
    public int LocationX { get; set; } = 100;
    public int LocationY { get; set; } = 150;
    public FormWindowState WindowState { get; set; } = FormWindowState.Normal;
    public string OtherText { get; set; } = "Some other text";
}

Сериализовать такой набор не сложно, но слишком скучно, надо бы добавить к свойствам немного информативности через класс атрибутов:

class PropertyInfoAttribute : Attribute
{
    public string Category { get; set; }
    public string Description { get; set; }
}

Этот класс можно запихнуть прямо в класс Settings, чтобы не мешался, да и всё равно он больше нигде не нужен. И тогда можно задавать свойства для наших настроек так:

public class Settings
{
    [PropertyInfo(Category = "Main Window", Description = "Whether main window should be on top")]
    public bool TopMost { get; set; } = false;

    [PropertyInfo(Category = "Main Window", Description = "Start X location")]
    public int LocationX { get; set; } = 100;

    [PropertyInfo(Category = "Main Window", Description = "Start Y location")]
    public int LocationY { get; set; } = 150;

    [PropertyInfo(Category = "Main Window", Description = "Start window state Normal/Maximized/Minimized")]
    public FormWindowState WindowState { get; set; } = FormWindowState.Normal;

    [PropertyInfo(Category = "Other", Description = "Some other text information")]
    public string OtherText { get; set; } = "Some other text";
    
    class PropertyInfoAttribute : Attribute
    {
        public string Category { get; set; }
        public string Description { get; set; }
    }
}

Всегда следует назначать свойствам значения по-умолчанию, или хотя бы до вызова методов загрузки и сохранения. Свойства (имеющие тип объекта) со значением null не будут сохраняться или загружаться, так как нельзя вызвать методы ToString или Parse у null.

Сохранение

Теперь напишем простой код для сохранения всех этих данных (сам код добавим в класс Settings):

string defPath = Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, "settings.ini");

class PropInfo
{
    public string Category;
    public string Name;
    public object Value;
    public string Description;
}

List<PropInfo> getPropInfoList(object obj, bool sort)
{
    List<PropInfo> pi = new List<PropInfo>();
    var props = obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance);
    foreach (var p in props)
    {
        var attr = p.GetCustomAttributes(true).OfType<PropertyInfoAttribute>().FirstOrDefault();
        pi.Add(new PropInfo() { Name = p.Name, Value = p.GetValue(this, null), Category = attr?.Category ?? "All", Description = attr?.Description });
    }
    if (sort) pi.Sort((a, b) => string.Compare(a.Category + a.Name, b.Category + b.Name));
    return pi;
}

public void Save() => Save(defPath, true);
public void Save(string filename, bool writeDescriptions)
{
    var pi = getPropInfoList(this, true);
    List<string> lines = new List<string>();

    string currentCatName = null;
    foreach (var p in pi)
    {
        if (p.Category != currentCatName)
        {
            if (lines.Count > 0) lines.Add("");
            lines.Add($"[{p.Category}]");
            currentCatName = p.Category;
        }
        if (p.Value != null)
        {
            if (writeDescriptions && p.Description != null) lines.Add("# " + p.Description);
            lines.Add($"{p.Name}={escape(p.Value.ToString())}");
        }
    }
    File.WriteAllLines(filename, lines);
}

string escape(string s) => s.Replace("\r", "<CR>").Replace("\n", "<LF>");

Сохранять я обычно предпочитаю в файл settings.ini, который лежит в папке с приложением. Для этого есть перегруженный метод Save без параметров. У ini файлов работает подсветка синтаксиса в текстовых редакторах типа notepad++ и подобных. Поэтому и комментарии с символом "#".

Метод getPropInfoList создаёт список объектов PropInfo, в которые мы с помощью рефлексии извлекаем самые нужные данные о свойствах заданного объекта. В нашем случае, текущего объекта this. И затем метод сортирует список в алфавитном порядке по Category + Name.

Метод Save(string, bool), получив такой список, просто сохраняет его построчно, вставляя названия категорий, пустые строки между категориями и комментарии Description для свойств, если таковые были указаны в атрибутах.

Если никакой категории в атрибутах не указать, то по умолчанию свойство получит категорию [All]. Если не указать Description, то строки комментария перед строкой свойства не будет. Можно делать и многострочные комментарии, просто вставляя в текст переносы, например так "Comment line1\r\n//# Comment line2..."

При сохранении значения самого свойства используется метод escape для заворачивания переносов строки в какие-нибудь нейтральные значения. Переносы - это единственное, что не допустимо в строках значений, все остальные символы, в том числе знак равенства использовать можно.

В результате запуска метода Save получим такой файл:

[Main Window]
# Start X location
LocationX=100
# Start Y location
LocationY=150
# Whether main window should be on top
TopMost=False
# Start window state Normal/Maximized/Minimized
WindowState=Maximized

[Other]
# Some other text information
OtherText=Some other text

Загрузка

Парсинг текстового файла - дело чуть более сложное. Могут возникать ошибки разбора строк и их преобразования в значения объектов. В коде не будут генерироваться исключения по каждому поводу, проще создать список ошибок, доступный через public методы.

Код загрузки также добавляется в класс Settings.

List<string> parseErrors = new List<string>();
public List<string> GetParseErrors() => parseErrors;
public int GetParseErrorsCount() => parseErrors.Count;

public void Load() => Load(defPath);
public void Load(string filename)
{
    if (!File.Exists(filename))
    {
        parseErrors.Add($"No settings file {filename}, default one is created");
        Save();
        return;
    }
    var lines = File.ReadAllLines(filename);
    Load(lines);
}

public void Load(string[] lines)
{
    parseErrors.Clear();
    var t = this.GetType();
    foreach (string line in lines)
    {
        if (line.Length > 0 && char.IsLetter(line[0]))
        {
            int pos = line.IndexOf('=');
            string name = pos >= 0 ? line.Substring(0, pos) : line;
            string value = pos >= 0 ? unescape(line.Substring(pos + 1)) : "";
            var p = t.GetProperty(name);
            if (p != null)
            {
                Type pt = p.PropertyType;
                object v = null;
                try
                {
                    if (pt == typeof(string)) v = value;
                    else if (pt.IsEnum) v = Enum.Parse(pt, value);
                    else
                    {
                        var mi = pt.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, null, new Type[] { typeof(string) }, null);
                        if (mi != null) v = mi.Invoke(null, new object[] { value });
                        else if (p.GetValue(this, null) != null)
                        {
                            mi = pt.GetMethod("Parse", BindingFlags.Public | BindingFlags.Instance, null, new Type[] { typeof(string) }, null);
                            if (mi != null) mi.Invoke(p.GetValue(this, null), new object[] { value });
                            else parseErrors.Add($"No parser for {pt.Name}");
                        }
                    }

                    if (v != null) p.SetValue(this, v, null);
                }
                catch { parseErrors.Add($"Parsing failed for {name}={value}"); }
            }
            else parseErrors.Add($"No property in object with name {name}");
        }
    }
}

string unescape(string s) => s.Replace("<CR>", "\r").Replace("<LF>", "\n");

Методы GetParseErrors и GetParseErrorsCount позволят получить список ошибок при разборе файла. Две первые перегрузки Load - просто обёртки для упрощения работы с основным Load(string[]).

В нём мы перебираем строки и отбрасываем все, что начинаются не с буквы. Таким образом, в коде сохранения, приведённом выше, можно сделать любой заголовок для комментирования строки хоть сишный "// ", хоть питоновский "# ", хоть XMLевский "<!-- -->". Парсер игнорирует строки, которые начинаются не с буквы. Да и заголовки категорий также можно оформить как угодно, заменив в строке lines.Add($"[{p.Category}]") квадратные скобки на другие символы, лишь бы первый не был буквой. А вот названия свойств должны начинаться с буквы.

Найдя строку свойства код делит его на имя и значение по первому знаку '=' и ищет такое имя свойства в классе. Если нашли, то определяем тип, и исходя из типа выбираем метод разбора и преобразования значения в объект с типом, соответствующим типу свойства. После чего через SetValue устанавливаем значение. Строки просто сохраняются как есть, Enum парсится через свой метод Parse(Type, Value). А вот для остальных типов пробуем найти другие методы.

Сначала ищется статический метод T T.Parse(string), возвращающий объект искомого типа T. Например int int.Parse(string). Для базовых системных типов он есть и имеет действие обратное методу ToString(), что нам и нужно.

Если такого метода нет, то ищем метод экземпляра void Parse(string), если экземпляр не null, конечно. И вызываем его, если нашли, чтобы экземпляр сам наполнил себя данными из строкового параметра.

Вообще странно, что майки не догадались сделать встроенный в класс object метод FromString(string). Ведь ToString() же сделали, чтобы любые объекты могли представлять себя в строковом виде, а обратный метод почему-то не завезли. Это бы на порядок упростило код парсера и избавило от ковыряния в списках методов объекта через рефлексию в надежде найти подходящий метод Parse.

Доработка типов

Простые типы умеют в ToString и Parse, это понятно, но что делать со сложными? Например, если хочется сохранить List? Да просто унаследоваться и прикрутить эти методы.

Например так, с парсингом через статический метод:

public class StringList : List<string>
{
    public StringList(string init) => Parse(init);
    public override string ToString() => String.Join(",", this);
    public static StringList Parse(string s)
    {
        var r = new StringList("");
        r.AddRange(s.Split(new string[] { "," }, StringSplitOptions.None));
        return r;
    }
}

Или так, с парсингом через метод экземпляра:

public class StringList : List<string>
{
    public StringList(string init) => Parse(init);
    public override string ToString() => String.Join(",", this);
    public void Parse(string s)
    {
        Clear();
        AddRange(s.Split(new string[] { "," }, StringSplitOptions.None));
    }
}

Конечно, в данном примере предполагается, что сами строки не содержат ",", иначе надо придумать другую строку в качестве разделителя.

Теперь свойство

[PropertyInfo(Category = "Special", Description = "User roles list")]
public StringList Roles { get; set; } = new StringList("Admin,User,Idiot");

будет сохраняться в удобочитаемом виде

# User roles list
Roles=Admin,User,Idiot

Пример 2

Конечно, так не всегда получится, тип может оказаться и запечатанным, тогда проще сделать обёртку, например (используется кисть SolidBrush из System.Drawing):

public class SBrush
{
    public SolidBrush Brush { get; private set; }
    public SBrush(int a, int r, int g, int b) => Brush = new SolidBrush(Color.FromArgb(a, r, g, b));
    ~SBrush() => Brush?.Dispose();
    public override string ToString() => Brush.Color.ToString();
    public void Parse(string s)
    {
        var m = Regex.Match(s, @"A=(\d+).+R=(\d+).+G=(\d+).+B=(\d+)");
        if (m.Success)
        {
            Brush?.Dispose();
            Brush = new SolidBrush(Color.FromArgb(int.Parse(m.Groups[1].Value), int.Parse(m.Groups[2].Value), int.Parse(m.Groups[3].Value), int.Parse(m.Groups[4].Value)));
        }
    }
}

Заодно в этом коде и проконтролируем высвобождение неуправляемых ресурсов кисти через вызовы Dispose.

свойство с объектом этого типа может выглядеть так:

[PropertyInfo(Category = "Special", Description = "Background brush")]
public SBrush BackBrush { get; set; } = new SBrush(255, 200, 100, 100);

а в файле settings.ini так:

# Background brush
BackBrush=Color [A=255, R=200, G=100, B=100]

Такой причудливый формат даёт встроенный System.Drawing.Color.ToString(), я не стал переделывать в этом примере. Хотя, ничто не мешает нам сделать вывод в стиле BackBrush=#FFC86464.

Таким образом мы получаем полный контроль над тем, как структурировать данные в нашем файле настроек, не имеем проблем с сериализаторами и их капризами, не зависим от толстых внешних библиотек.

Конечно, прикручивать классам ToString и Parse может показаться некоторой рутиной, однако, для небольших утилит, для которых хорошо подходит такой формат хранения настроек, как показывает опыт, этого делать почти не приходится. Редко требуется сохранять свойства со сложными типами, в основном все настройки примитивны.

Итоговый код

Соединим код всего класса Settings.cs

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Reflection;
using System.Windows.Forms;

namespace SerializerTest
{
    public class Settings
    {
        [PropertyInfo(Category = "Main Window", Description = "Whether main window should be on top")]
        public bool TopMost { get; set; } = false;

        [PropertyInfo(Category = "Main Window", Description = "Start X location")]
        public int LocationX { get; set; } = 100;

        [PropertyInfo(Category = "Main Window", Description = "Start Y location")]
        public int LocationY { get; set; } = 150;

        [PropertyInfo(Category = "Main Window", Description = "Start window state Normal/Maximized/Minimized")]
        public FormWindowState WindowState { get; set; } = FormWindowState.Normal;

        [PropertyInfo(Category = "Other", Description = "Some other text information")]
        public string OtherText { get; set; } = "Some other text";



        string defPath = Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, "settings.ini");

        class PropertyInfoAttribute : Attribute
        {
            public string Category { get; set; }
            public string Description { get; set; }
        }


        // --- Save ---

        class PropInfo
        {
            public string Category;
            public string Name;
            public object Value;
            public string Description;
        }

        List<PropInfo> getPropInfoList(object obj, bool sort)
        {
            List<PropInfo> pi = new List<PropInfo>();
            var props = obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance);
            foreach (var p in props)
            {
                var attr = p.GetCustomAttributes(true).OfType<PropertyInfoAttribute>().FirstOrDefault();
                pi.Add(new PropInfo() { Name = p.Name, Value = p.GetValue(this, null), Category = attr?.Category ?? "All", Description = attr?.Description });
            }
            if (sort) pi.Sort((a, b) => string.Compare(a.Category + a.Name, b.Category + b.Name));
            return pi;
        }

        public void Save() => Save(defPath, true);
        public void Save(string filename, bool writeDescriptions)
        {
            var pi = getPropInfoList(this, true);
            List<string> lines = new List<string>();

            string currentCatName = null;
            foreach (var p in pi)
            {
                if (p.Category != currentCatName)
                {
                    if (lines.Count > 0) lines.Add("");
                    lines.Add($"[{p.Category}]");
                    currentCatName = p.Category;
                }
                if (p.Value != null)
                {
                    if (writeDescriptions && p.Description != null) lines.Add("# " + p.Description);
                    lines.Add($"{p.Name}={escape(p.Value.ToString())}");
                }
            }
            File.WriteAllLines(filename, lines);
        }


        // --- Load ---

        List<string> parseErrors = new List<string>();
        public List<string> GetParseErrors() => parseErrors;
        public int GetParseErrorsCount() => parseErrors.Count;

        public void Load() => Load(defPath);
        public void Load(string filename)
        {
            if (!File.Exists(filename))
            {
                parseErrors.Add($"No settings file {filename}, default one is created");
                Save();
                return;
            }
            var lines = File.ReadAllLines(filename);
            Load(lines);
        }

        public void Load(string[] lines)
        {
            parseErrors.Clear();
            var t = this.GetType();
            foreach (string line in lines)
            {
                if (line.Length > 0 && char.IsLetter(line[0]))
                {
                    int pos = line.IndexOf('=');
                    string name = pos >= 0 ? line.Substring(0, pos) : line;
                    string value = pos >= 0 ? unescape(line.Substring(pos + 1)) : "";
                    var p = t.GetProperty(name);
                    if (p != null)
                    {
                        Type pt = p.PropertyType;
                        object v = null;
                        try
                        {
                            if (pt == typeof(string)) v = value;
                            else if (pt.IsEnum) v = Enum.Parse(pt, value);
                            else
                            {
                                var mi = pt.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, null, new Type[] { typeof(string) }, null);
                                if (mi != null) v = mi.Invoke(null, new object[] { value });
                                else if (p.GetValue(this, null) != null)
                                {
                                    mi = pt.GetMethod("Parse", BindingFlags.Public | BindingFlags.Instance, null, new Type[] { typeof(string) }, null);
                                    if (mi != null) mi.Invoke(p.GetValue(this, null), new object[] { value });
                                    else parseErrors.Add($"No parser for {pt.Name}");
                                }
                            }

                            if (v != null) p.SetValue(this, v, null);
                        }
                        catch { parseErrors.Add($"Parsing failed for {name}={value}"); }
                    }
                    else parseErrors.Add($"No property in object with name {name}");
                }
            }
        }

        string escape(string s) => s.Replace("\r", "<CR>").Replace("\n", "<LF>");
        string unescape(string s) => s.Replace("<CR>", "\r").Replace("<LF>", "\n");

        public void StartProcess() => System.Diagnostics.Process.Start(defPath);

    }
}

Мне кажется, получилось довольно просто и компактно.

В конце еще добавил метод StartProcess(), который позволяет запустить текущий текстовый файл с настройками. То есть если в системе выбрано приложение для редактирования ini файлов, то наш текстовый файл откроется в нём. Это на тот случай, когда лень делать редактор настроек в приложении.

Пример использования

Простой пример использования такого класса настроек приложения

public partial class Form1 : Form
{
    Settings settings = new Settings();

    public Form1()
    {
        InitializeComponent();
        
        loadSettings();
        this.FormClosing += (s, e) => saveSettings();
    }

    void saveSettings()
    {
        settings.TopMost = this.TopMost;
        settings.LocationX = this.Location.X;
        settings.LocationY = this.Location.Y;
        settings.WindowState = this.WindowState;
        settings.Save();
    }

    void loadSettings()
    {
        settings.Load();
        if (settings.GetParseErrorsCount() > 0)
            MessageBox.Show(String.Join("\r\n", settings.GetParseErrors().ToArray()));

        this.TopMost = settings.TopMost;
        this.Location = new Point(settings.LocationX, settings.LocationY);
        this.WindowState = settings.WindowState;
    }
}

Можно еще придумать привязку данных, которые мы и так перекидываем в свойства другого объекта, как настройки формы из примера, просто чтобы избежать списков выражений присваиваний в методах loadSettings и saveSettings. Но это уже выходит за рамки данной статьи.

Ещё следует учесть один момент. Некоторые методы Parse, например для нецелых чисел типа double, могут учитывать культурные особенности и использовать "," вместо "." в разделителе целой и дробной части числа. Так что если файл с сохранёнными настройками перенести на другую машину с иными региональными настройками системы, то часть настроек может не считаться корректно. Для решения этой ситуации можно заставить приложение использовать нейтральные настройки InvariantCulture. Для этого надо просто добавить в начало конструктора Form1 строку

System.Threading.Thread.CurrentThread.CurrentCulture = System.Globalization.CultureInfo.InvariantCulture;

Итоги

Получился очень простой класс описания настроек, их сохранения в текстовый файл, а также последующей загрузки.

Преимущества такого способа:

  • Сохраняет в текстовый файл

  • Легко редактировать настройки любым "блокнотом"

  • Гибкость формата сохранения, возможность выбирать формат и структуру для своих типов

  • Простой код, нет внешних зависимостей

Недостатки:

  • Одноуровневая сериализация, надо добавлять ToString и Parse для сложных типов

  • Может не подойти для программ со сложными структурированными настройками

Что ещё можно доработать:

  • Добавить механизм привязки (bindings).

  • Сделать шаблоны сохранения типов, чтобы не писать классы обёртки для сериализации типов, не имеющих механизма сохранения через методы ToString и Parse.

Примеры применения в проектах

Такой способ сохранения настроек я использовал в некоторых своих проектах. Например, из опубликованных на данный момент:

Комментарии (27)


  1. menelion_elensule
    02.11.2024 11:43

    Я в экосистеме .NET недавно (с год примерно), поэтому, может, кто-то найдёт какие-то неувязки, но мне этот подход кажется вполне хорошим. Правда, я таки люблю обращаться к настройкам в категориях вроде такого: Config.MainWindow.InitialState. Поэтому лично я использую библиотеку под названием SharpConfig (не реклама, авторы не знают про этот коммент :)).


    1. questfulcat Автор
      02.11.2024 11:43

      Да, интересная библиотека, не знал о ней.

      Мне же нужен был маленький класс без внешних зависимостей, который удобно перекидывать из проекта в проект и просто добавлять туда настройки. И чтобы любой сторонний пользователь мог в текстовом редакторе их менять.


  1. VanKrock
    02.11.2024 11:43

    а чем вам appsettings.json не подошёл?
    И Microsoft.Extensions.Configuration


    1. menelion_elensule
      02.11.2024 11:43

      Честно? Банально не дошёл до Microsoft.Extensions.Configuration. Знаю, что его вроде можно прикрутить к WindowsForms-приложению тоже, а для меня Windowsforms самое важное.


    1. questfulcat Автор
      02.11.2024 11:43

      Во-первых, appsettings.json появился в .NET Core, а я в основном делаю десктопные приложения и использую .NET Framework, там его надо прикручивать как лишнюю зависимость. Мне удобнее иметь один небольшой класс в проекте.

      Во-вторых, разве в json можно делать комментарии к настройкам, разбивать их на группы, и предоставлять всё это пользователю в удобочитаемом виде? Я же написал, почему отказался от XML и json.


      1. DanteLFC
        02.11.2024 11:43

        Ну то есть вы используете уже довольно лютый легаси и случайно забываете упоминать это в статье, чтобы люди нашли вашу статью и реально подумали что такая ручная сериализация это бест практис? В json как раз есть группировка по сложным объектам и комментарии и не надо думать что там с вашими параметрами


        1. questfulcat Автор
          02.11.2024 11:43

          Да, приходится использовать легаси .NET Framework потому, что он больше подходит для многих задач десктоп разработки, например из-за кроссплатформенного winforms.

          Я и не заявлял это как бест практис, а привёл то решение, к которому пришёл как к более удобному в рамках моих задач, после мороки с XML и JSON, вдруг кому-то пригодится...

          И про JSON - что будет, если пользователь, редактируя JSON в блокноте, сотрёт там случайно какую-нибудь скобку или кавычку? Он ведь может быть не знаком с программированием и структурами JSON. А описанная INI-подобная структура файла настроек, на мой взгляд, понятна и проста для интуитивного восприятия.


          1. Einherjar
            02.11.2024 11:43

            из-за кроссплатформенного winforms

            это как?

            что будет, если пользователь, редактируя JSON в блокноте, сотрёт там случайно

            А зачем пользователю, тем более такому который не знаком с JSON и случайно что то стирает, в общем случае вообще руками может понадобиться лезть править конфиг, причем в блокноте без подсветки синтаксиса? Преимущества вашего способа имхо высосаны из пальца, а недостатки достаточно существенны чтобы проигрывать даже практически всем встроенным сериализаторам. Просто добавляется еще одна потенциальная точка отказа на ровном месте. Одна только необходимость установки CurrentCulture уже весьма попахивает.

            Кстати, всякие там продвинутые текстовые редакторы которыми обычно все пользуются (а ля notepad++ и иже с ними) json и xml прекрасно подсвечивают, а всякий колхоз - нет.


            1. questfulcat Автор
              02.11.2024 11:43

              это как?

              У меня winforms приложения собранные в .NET Framework нормально запускаются под mono в linux. В .NET winforms только под windows вроде бы...

              А зачем пользователю, тем более такому который не знаком с JSON и случайно что то стирает, в общем случае вообще руками может понадобиться лезть править конфиг, причем в блокноте без подсветки синтаксиса? Преимущества вашего способа имхо высосаны из пальца, а недостатки достаточно существенны чтобы проигрывать даже практически всем встроенным сериализаторам.

              Так я реализую настройки в проектах, в которых ещё не успел сделать меню настроек. Например, в проекте SolidModelBrowser (ссылка есть в статье), нажав на кнопку настроек с гаечным ключиком, просто запускается процесс для файла settings.ini. Откроется не блокнот, а текущий ассоциированный с ini файлами редактор, у меня это notepad++ с подсветкой синтаксиса и прочими удобствами, мой "колхоз" вполне неплохо подсвечивает.

              То, что вы считаете недостатками, для другого может быть достоинствами. Например, реализация для какого-то класса своего метода Parse - позволяет гибко сохранять данные так, как нам хочется, а не в виде трёхэтажного дерева из скобок и кавычек. В упомянутом выше примере более 30 настроек, и потребовалось создать только один класс-обёртку для сохранения цвета, что заняло несколько строчек кода. И объект цвета сохраняется не в виде кучи отдельных свойств а в удобном мне формате, например "DiffuseColor=#FFFFFF00" .


              1. GeKtvi
                02.11.2024 11:43

                Если проблема с кросплатформенустью почему бы не использовать кросплатформенные наследники WPF, по типу AvaloniaUI, вместо старых WF? Как я понял, у вас часть проектов на WPF, перекинуть это дело на авалонию, как по мне, быстрее, чем содержать несколько вариантов UI.

                А насчёт сложности XML, JSON, соглашусь, процентов 70% пользователей в обморок падает если при них открыть такой конфиг и попросить отредачить. Хотя если попинать то они на удивление быстро понимают что к чему.


                1. questfulcat Автор
                  02.11.2024 11:43

                  AvaloniaUI и WPF это очень разные платформы. Например, в авалонии я не видел аналогов контролу Viewport3D и если нужен 3D, его придётся делать через OpenGL, а уж если делать через OpenGL то и авалонию нет необходимости довешивать к проекту.

                  Потом, релиз на авалонии получается в десятки или даже сотни мегабайт. А подобное по функционалу приложение на WPF (если под .NET Framework собирать) получается сотни килобайт.

                  В общем, авалония пока выигрывает только в кроссплатформенности перед WPF. И если разработка не требует других систем кроме windows, то WPF пока остаётся лучшим выбором, на мой взгляд.

                  Хотя если попинать то они на удивление быстро понимают что к чему.

                  Если приложение публикуется для широкого круга пользователей, вряд ли такой вариант подходит.


                  1. GeKtvi
                    02.11.2024 11:43

                    Да в авалонии нет ViewPort3D. Рендерят только через враперы OpenGL/Vulkan, насколько я знаю. Не совсем понял почему при использовании API отрисовки не нужен UI фреймворк. Да и речь шла о использовании WF под линукс, а насколько я знаю в формах тоже нет контрола для отображения 3D.

                    Ну и по весу приложения действительно не очень оптимизировано получается, иногда нужно приложение с одной кнопкой, а оно весит за сотню). Компрессия иногда спасает. WPF тут будет выигрывать только до тех пор, пока его не уберут из стандартной установки винды.

                    А если пользователей пинать то они, обычно, обижаються) Так что с простым вариантом ini подобных настроек вполне согласен, часто сам страдаю из-за необходимости городить огород ради сохранения двух галочек у пользователя.


                    1. questfulcat Автор
                      02.11.2024 11:43

                      Тут я просто имел в виду, что если уж делать движок для основной отрисовки с OpenGL, то можно взять куда более легковесный OpenTK, или другие C# врапперы для OpenGL, в том числе под WF. А интерфейс, если он не сложный, тогда уж тоже сделать в OpenGL.

                      Ещё я не знаю аналога MediaElement в авалонии. Очень нужный контрол для работы с видео.


                    1. Einherjar
                      02.11.2024 11:43

                      Ну и по весу приложения действительно не очень оптимизировано получается, иногда нужно приложение с одной кнопкой, а оно весит за сотню)

                      С NativeAOT около 15-20 мегабайт получается. И это точно лучше и меньше чем тащить с собой всякие тормозные костыли вроде mono


            1. questfulcat Автор
              02.11.2024 11:43

              Никакой необходимости установки CurrentCulture нет. Это было описано только для тех специфических случаев, если вы используете для сохранения встроенные методы ToString у типов с регионально зависимым форматированием, например тип double, и при этом копируете (зачем-то) файл настроек с машины с одними региональными настройками в машину с другими настройками.


          1. DanteLFC
            02.11.2024 11:43

            Прощу прощения, но если ваш пользователь необученная обезьяна - он сломает любой формат. А если он условно уверенный айти-пользователь, то в 2024 json гораздо популярнее и ошибиться в нем тяжелее и любой текстовый редактор нормально его подсвечивает. Мне кажется, вы пытаетесь придумать велосипед там где он излишен


            1. questfulcat Автор
              02.11.2024 11:43

              К любому пользователю стоит относиться с максимальным уважением. В моём формате пользователь может искаверкать любую строку, и не загрузится только настройка из этой строки, вместо неё будет использована настройка по-умолчанию. Уберите одну скобку в JSON и результат десериализации может быть непредсказуем.

              К тому же можно добавлять новые или удалять настройки и файл со старой структурой без проблем подгрузится.

              Мой велосипед излишен для вас лично, а мне он нравится, удобный он для меня, и я буду его развивать.


  1. Einherjar
    02.11.2024 11:43

    Наигравшись с тормозами и проблемами XMLSerializer, заодно разочаровался и в самом XML

    А что вы такого делаете с настройками приложения что они тормозят?


    1. questfulcat Автор
      02.11.2024 11:43

      Тут проблема не с количеством настроек. У меня были случаи, когда использование XMLSerializer при запуске приложения заметно увеличивало время запуска.

      И ещё, когда я использовал глобальный перехват исключений, XMLSerializer забивал логи, там постоянно внутри происходили какие-то исключения, которые он сам же и перехватывал, что в итоге мне мешало.


      1. Einherjar
        02.11.2024 11:43

        Ну, есть еще DataContractSerializer, он ничего не бросает, его и настроить легко и никаких методов Parse писать в каждом своем классе не надо.


        1. questfulcat Автор
          02.11.2024 11:43

          Да есть, конечно, и ещё много чего есть со своими достоинствами и недостатками. Но я строил удобный велосипед для себя, простой, легковесный, короче такой, какой мне нравится. Подумал, может кому-то ещё пригодится - выложил.

          Кстати, в атрибуте PropertyInfo кроме свойств Category и Description я планировал дальше добавить Binding, чтобы свойство байндилось к другому свойству внешнего объекта автоматом, и свойство SavePattern, чтобы можно было строчкой описать шаблон сохранения объекта, тогда и Parse писать не придётся. Но, не беспокойтесь, писать про это статью сюда, я наверное уже не буду... :)


  1. MicrofCorp
    02.11.2024 11:43

    А почему просто не создать структуру и серелизовать ее в файл?


    1. questfulcat Автор
      02.11.2024 11:43

      Каким образом предлагается её сериализовать?


      1. LightSUN
        02.11.2024 11:43

        var json = JsonConvert.SerializeObject(settings);


        1. questfulcat Автор
          02.11.2024 11:43

          так мне же не подходит JSON, выше обсуждалось это


      1. MicrofCorp
        02.11.2024 11:43

        Через атрибут [Serializable]

        И класс FileStream


        1. questfulcat Автор
          02.11.2024 11:43

          не все классы помечены как [Serializable], например, если свойство имеет тип System.Drawing.SolidBrush, как его сериализовать?