Способ сохранения и загрузки настроек .NET приложения
Наверняка многие разработчики сталкивались с необходимостью сохранения настроек своих приложений в файл и использовали для достижения этой цели различные сериализаторы типа 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 getPropInfoList(object obj, bool sort)
{
List pi = new List();
var props = obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var p in props)
{
var attr = p.GetCustomAttributes(true).OfType().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 lines = new List();
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", "").Replace("\n", "");
Сохранять я обычно предпочитаю в файл 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 parseErrors = new List();
public List 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("", "\r").Replace("", "\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
{
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
{
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 getPropInfoList(object obj, bool sort)
{
List pi = new List();
var props = obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var p in props)
{
var attr = p.GetCustomAttributes(true).OfType().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 lines = new List();
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 parseErrors = new List();
public List 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", "").Replace("\n", "");
string unescape(string s) => s.Replace("", "\r").Replace("", "\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.
Примеры применения в проектах
Такой способ сохранения настроек я использовал в некоторых своих проектах. Например, из опубликованных на данный момент: