Хранение и получение данных между сессиями в NET
В продолжение настроек под Linux и хранения пользовательских типов данных
Прежде всего: я ковырялся с AppSettings и ConfigurationManager. Возможно, я не умею их готовить, но как-то они "не зашли". Да и самому было интересно поиграться.
Вопрос номер раз - где и как хранить настройки? В реестре? Серьезно? Учитывая движение в сторону Linux? Так что реестр отпадает. Остается вариант хранения в файле. И вот тут ситуация уже не настолько прозрачна, как хотелось бы. Требований к хранению не так уж и много:
- Все должно работать без диких тормозов
- Настройки должны быть человекочитабельны
Вариантов хранения, получается, на самом деле не так уж и много: ini | xml | json. Бинарное хранение не рассматриваю: головняка с ним можно огрести массу, а преимущества - только не самая продвинутая защита от стороннего доступа.
А какой формат файла выбрать?
Лично я предпочту xml. ini достаточно сложен для многослойной структуры, json - точнее, методы его чтения / записи - могут вызывать приличное количество проблем при использовании того же знаменитого Newton.Json (а с альтернативами не сильно густо, по моим ощущениям): конфликт версий библиотек никто не отменял. В то же время работа с xml вроде как подобного конфликта породить не может по определению. Так что упираюсь в него.
Предлагаю идти по шагам, постепенно усложняя задачи. Для начала рисую пример xml-файла, в котором будут храниться общие настройки:
1 2 3 4 5 6 7 8 | <?xml version="1.0"?> <configuration xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <userSettings> <setting name="setting1" value="value1" /> <setting name="setting2" value="1" /> <setting name="setting3" value="True" /> </userSettings> </configuration> |
Понятно, что это только пример и имеет весьма слабое отношение к реальной жизни. Попробую сделать код для работы с этими настройками. Создам консольное приложение AppSettingsConsole (можно и NET6+, и NET Framework - фиолетово), и рядом - библиотеку NET STandard 2.0 с именем, например, AddOnCore. Именно в библиотеке и будет выполняться основная работа, а консоль только для легкого контроля.
Как бы мне ни хотелось, а придется, похоже, класс работы с настройками делать статическим. По крайней мере вначале.
Класс должен как минимум хранить в себе полное имя файла настроек. Ну и неплохо было бы заодно написать "сбрасыватель" настроек:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | using System.IO; using System.Reflection; namespace AddOnCore { public static class AppSettings { /// <summary> Очищает настройки, удаляя файл с сохраненными настройками </summary> public static void ClearSettings() { if (File.Exists(_configFileName)) { File.Delete(_configFileName); } } /// <summary> Возвращает полный путь к файлу с настройками </summary> public static string ConfigFileName => _configFileName; private static string _configFileName = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "AppSample.user.config"); #region Seralization // Примечание. Для запуска созданного кода может потребоваться NET Framework версии 4.5 или более поздней версии и .NET Core или Standard версии 2.0 или более поздней. /// <remarks/> [System.SerializableAttribute()] [System.ComponentModel.DesignerCategoryAttribute("code")] [System.Xml.Serialization.XmlTypeAttribute(AnonymousType = true)] [System.Xml.Serialization.XmlRootAttribute(Namespace = "", IsNullable = false)] public partial class configuration { private configurationSetting[] userSettingsField; /// <remarks/> [System.Xml.Serialization.XmlArrayItemAttribute("setting", IsNullable = false)] public configurationSetting[] userSettings { get { return this.userSettingsField; } set { this.userSettingsField = value; } } } /// <remarks/> [System.SerializableAttribute()] [System.ComponentModel.DesignerCategoryAttribute("code")] [System.Xml.Serialization.XmlTypeAttribute(AnonymousType = true)] public partial class configurationSetting { private string nameField; private string valueField; /// <remarks/> [System.Xml.Serialization.XmlAttributeAttribute()] public string name { get { return this.nameField; } set { this.nameField = value; } } /// <remarks/> [System.Xml.Serialization.XmlAttributeAttribute()] public string value { get { return this.valueField; } set { this.valueField = value; } } } #endregion } } |
Чтоб особенно не париться, сюда же добавил сериализацию, которую предоставляет сама VS при специальной вставке XML как классов.
Имя конфигурационного файла, можно сказать, пока что "приколочено гвоздями", и этот config будет располагаться рядом с основной библиотекой AddOnCore, где б она ни использовалась.
Теперь добавлю, к примеру, настройки примерно такого вида:
ConnectionString - строка подключения к базе данных.
LastObjectTypeId - какой-то Id типа объекта, с которым в последний раз работали. Целое число
ShowSplash - булево значение. Типа "показывать или нет окно заставки"
Чтоб со всем этим богатством можно было работать, добавлю два приватных метода для получения и сохранения значения:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | private static void SetValue(string key, string value) { if (!Directory.Exists(Path.GetDirectoryName(_configFileName))) { Directory.CreateDirectory(Path.GetDirectoryName(_configFileName)); } XmlSerializer ser = new XmlSerializer(typeof(configuration)); configuration conf = new configuration(); if (!File.Exists(_configFileName)) { conf.userSettings = new configurationSetting[] { new configurationSetting() { name = key, value = value, } }; using (FileStream fs = new FileStream(_configFileName, FileMode.Create)) { ser.Serialize(fs, conf); } } else { using (FileStream fs = new FileStream(_configFileName, FileMode.Open)) { conf = ser.Deserialize(fs) as configuration; } configurationSetting item = conf.userSettings.FirstOrDefault(o => o.name == key); if (item == null) { conf.userSettings = conf.userSettings.Append(new configurationSetting() { name = key, value = value, }).ToArray(); } else { item.value = value; } File.Delete(_configFileName); using (FileStream fs = new FileStream(_configFileName, FileMode.Create)) { ser.Serialize(fs, conf); } } } private static string GetValue(string key, string defaultValue = null) { if (!File.Exists(_configFileName)) { return defaultValue; } XmlSerializer ser = new XmlSerializer(typeof(configuration)); configuration conf = new configuration(); using (FileStream fs = new FileStream(_configFileName, FileMode.Open)) { conf = ser.Deserialize(fs) as configuration; } return conf.userSettings.FirstOrDefault(o => o.name == key)?.value ?? defaultValue; } |
Теперь можно и геттеры/сеттеры для свойств прописывать:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | /// <summary> Строка подключения к БД </summary> public static string ConnectionString { get => GetValue(nameof(ConnectionString)); set => SetValue(nameof(ConnectionString), value); } /// <summary> Id последнего использованного типа объекта. Значение по умолчанию - 0 </summary> public static int LastObjectTypeId { get { if (int.TryParse(GetValue(nameof(LastObjectTypeId)), out var res)) return res; return 0; } set => SetValue(nameof(LastObjectTypeId), value.ToString()); } /// <summary>Показывать или нет окно заставки. Значение по умолчанию - true</summary> public static bool ShowSplash { get { if (bool.TryParse(GetValue(nameof(ShowSplash)), out var res)) return res; return true; } set => SetValue(nameof(ShowSplash), value.ToString()); } |
Теперь чисто по приколу в консоли добавлю запись настроек, и посмотрю, что будет в результате:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | using AddOnCore; using System; namespace AppSettingsConsole { internal class Program { static void Main(string[] args) { AppSettings.ClearSettings(); AppSettings.ConnectionString = "Some connectionString"; AppSettings.LastObjectTypeId = 164; AppSettings.ShowSplash = true; Console.WriteLine(AppSettings.ConfigFileName); Console.ReadKey(); } } } |
Запуск... Ура, оно запустилось, и даже не вывалило ошибку!
И даже файл AppSample.user.config имеется!
1 2 3 4 5 6 7 8 | <?xml version="1.0"?> <configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <userSettings> <setting name="ConnectionString" value="Some connectionString" /> <setting name="LastObjectTypeId" value="164" /> <setting name="ShowSplash" value="True" /> </userSettings> </configuration> |
Правда, как-то AppSample,user.config - ну так себе имечко, искать его тяжеловато. Так что поменяю-ка я имя конфига на AppSettings.user.config:
1 2 | private static string _configFileName = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), $"{nameof(AppSettings)}.user.config"); |
Это все хорошо, но касается только общих типов данных. А если добавить свое перечисление и его хранить / получать?
1 2 3 4 5 6 7 8 9 10 11 12 13 | namespace AddOnCore.Enums { /// <summary>Варианты округления чисел</summary> public enum RoundMethods { /// <summary>Неустановлено</summary> Unknown, /// <summary>До 2 знаков после запятой</summary> TwoDigits, /// <summary>Округлять как сырье (три знака после запятой для погонажа; для остального - 4 знака</summary> Stuff, } } |
Ну и соответственно в AppSettings добавлю:
1 2 3 4 5 6 7 8 9 10 | public static RoundMethods LastActivatedRoundMethod { get { if (Enum.TryParse(GetValue(nameof(LastActivatedRoundMethod)), out RoundMethods res)) return res; return RoundMethods.Unknown; } set => SetValue(nameof(LastActivatedRoundMethod), value.ToString()); } |
Ну и добавить в консоль строчку AppSettings.LastActivatedRoundMethod = AddOnCore.Enums.RoundMethods.TwoDigits;. В результате в конфиге будет нечто типа:
1 2 3 4 5 6 7 8 9 | <?xml version="1.0"?> <configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <userSettings> <setting name="ConnectionString" value="Some connectionString" /> <setting name="LastObjectTypeId" value="164" /> <setting name="ShowSplash" value="True" /> <setting name="LastActivatedRoundMethod" value="TwoDigits" /> </userSettings> </configuration> |
Пока что все нравится, можно коммитить
Но что будет, если понадобится хранить / получать настройки, которые зависят от када, в котором выполняется запуск аддона? Допустим, для наника одно, для акада - другое? Да еще и под разные версии? Ну и контрольный - добавить локализацию.
Первое движение, которое хочется сделать - это нарисовать вместо свойств два метода (один для получения, второй для сохранения), куда помимо значения передавать, к примеру, тип када, его версию, локализацию и черта лысого, объединив все это барахло в один класс / интерфейс типа такого:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | namespace AddOnCore { public class CadEnvironment { public CadEnvironment(string Name, string Version, string Localization = null) { this.Name = Name; this.Version = Version; this.Localization = Localization; } public string Name { get; } public string Version { get; } public string Localization { get; } } } |
Статический класс может иметь конструктор, но у этого конструктора не может быть параметров. Так что первое движение, похоже, имеет право на существование. Ну что ж, пропишу настройку - строку. К примеру, имя принтера по умолчанию (пока что ставлю заглушки):
1 2 3 4 5 6 7 8 9 |
И тут вопрос - а каким манером-то хранить? Можно создать многоуровневый xml, можно создавать каким-то манером имя настройки типа
Прежде чем двигаться дальше, напишу-ка тесты. Глазами проверять xml с настройками - ну так себе удовольствие. .csproj для тестового приложения получился таким:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net6.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <IsPackable>false</IsPackable> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" /> <PackageReference Include="NUnit.Analyzers" Version="3.3.0" /> <PackageReference Include="coverlet.collector" Version="3.1.2" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\..\AddOnCore\AddOnCore.csproj" /> </ItemGroup> </Project> |
Теперь пишу проверки уже созданного кода:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | using AddOnCore; using AddOnCore.Enums; using NUnit.Framework; namespace AppSettingsTests { public class AppSettingsCheck { [OneTimeSetUp] public void Startup() { AppSettings.ClearSettings(); } [Test] public void ConnectionString() { string connString = "Some connection string"; AppSettings.ConnectionString = connString; Assert.AreEqual(connString, AppSettings.ConnectionString); connString = "Some another connection string"; AppSettings.ConnectionString = connString; Assert.AreEqual(connString, AppSettings.ConnectionString); } [Test] public void LastObjectTypeId() { int id = 987; AppSettings.LastObjectTypeId = id; Assert.AreEqual(id, AppSettings.LastObjectTypeId); id = 123; AppSettings.LastObjectTypeId = id; Assert.AreEqual(id, AppSettings.LastObjectTypeId); } [Test] public void ShowSplash() { bool showSplash = false; AppSettings.ShowSplash = showSplash; Assert.AreEqual(showSplash, AppSettings.ShowSplash); showSplash = true; AppSettings.ShowSplash = showSplash; Assert.AreEqual(showSplash, AppSettings.ShowSplash); } [Test] public void LastActivatedRoundMethod() { RoundMethods method = RoundMethods.Stuff; AppSettings.LastActivatedRoundMethod = method; Assert.AreEqual(method, AppSettings.LastActivatedRoundMethod); method = RoundMethods.TwoDigits; AppSettings.LastActivatedRoundMethod = method; Assert.AreEqual(method, AppSettings.LastActivatedRoundMethod); } [Test] public void ConfigFileName() { Assert.AreEqual(AppSettings.ConfigFileName, Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "AppSettings.user.config")); } } }[ |
Тесты простенькие, но уже лучше чем ничего
Возвращаясь к настройкам, зависящим от окружения. Пожалуй, сначала сделаю через имя настройки - все ж это сильно проще, как мне кажется. Придется нарисовать метод получения имени настройки:
1 2 3 4 5 6 7 8 9 10 11 | /// <summary>Вычисление имени настройки, зависящей от окружения</summary> /// <param name="Env"></param> /// <param name="Name"></param> /// <returns></returns> private static string EvaluateSettingName(CadEnvironment Env, string Name) { return Env.Name + (string.IsNullOrWhiteSpace(Env.Version)? "" : ("." + Env.Version)) + (string.IsNullOrWhiteSpace(Env.Localization) ? "" : ("." + Env.Localization)) + Name; } |
В отдельной переменной храню имя настройки плоттера: private static readonly string _printerSettingName = "Printer";, и добиваю уже получение и хранение плоттера по умолчанию:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | /// <summary>Получение имени плоттера по умолчанию</summary> /// <param name="Env"></param> /// <returns></returns> /// <exception cref="NotImplementedException"></exception> public static string GetDefaultPrinterName(CadEnvironment Env) { return GetValue(EvaluateSettingName(Env, _printerSettingName)); } /// <summary>Назачение имени плоттера по умолчанию</summary> /// <param name="Env"></param> /// <param name="Value"></param> /// <exception cref="NotImplementedException"></exception> public static void SetDefaultPrinterName(CadEnvironment Env, string Value) { SetValue(EvaluateSettingName(Env, _printerSettingName), Value); } |
Чисто на поглядеть, как оно будет выглядеть, в консоли поиграюсь с этой настройкой:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | internal class Program { static void Main(string[] args) { AppSettings.ClearSettings(); AppSettings.ConnectionString = "Some connectionString"; AppSettings.LastObjectTypeId = 164; AppSettings.ShowSplash = true; AppSettings.LastActivatedRoundMethod = AddOnCore.Enums.RoundMethods.TwoDigits; CadEnvironment env = new CadEnvironment("CadName", "CadVersion"); AppSettings.SetDefaultPrinterName(env, "some plotter"); Console.WriteLine(AppSettings.ConfigFileName); Console.ReadKey(); } } |
В результате в конфиге получу нечто типа:
1 2 3 4 5 6 7 8 9 10 | <?xml version="1.0"?> <configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <userSettings> <setting name="ConnectionString" value="Some connectionString" /> <setting name="LastObjectTypeId" value="164" /> <setting name="ShowSplash" value="True" /> <setting name="LastActivatedRoundMethod" value="TwoDigits" /> <setting name="CadName.CadVersionPrinter" value="some plotter" /> </userSettings> </configuration> |
Вроде бы по крайней мере работает. Добавляю / дополняю тесты. Хотя мне это и не очень нравится (по эстетическим причинам), но продолжу тесты в том же классе:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | [Test] public void PrinterNameNoLocalization() { string printerName = "PrinterNoLocalization"; CadEnvironment env = new CadEnvironment("Cad", "Verstion"); AppSettings.SetDefaultPrinterName(env, printerName); Assert.AreEqual(AppSettings.GetDefaultPrinterName(env), printerName); } [Test] public void PrinterNameWithLocalization() { string printerName = "PrinterWithLocalization"; CadEnvironment env = new CadEnvironment("Cad", "Verstion", "Rus"); AppSettings.SetDefaultPrinterName(env, printerName); Assert.AreEqual(AppSettings.GetDefaultPrinterName(env), printerName); } |
Чем этот подход )как по мне) хорош - так это тем, что структуру хранения можно будет не затрагивать ни при каких обстоятельствах. Помимо локализации понадобилось учитывать имя ОС и / или ее версию? Ну и что, достаточно поменять метод вычисления имени настройки, и не более того.
Есть, конечно, проблема: настройки, зависимые от окружения, могут быть раскиданы по всему файлу тонким слоем. Я попытался сделать многоуровневое решение, но столкнулся с невероятным количеством возможных сочетаний настроек: нет локализации, но есть версия; нет версии, но есть локализация; и т.д., и т.п. Если количество настроек становится, к примеру, равным 4 (имя када, версия, локализация, версия ОС), то количество возможных уровней становится равным 4! (и это факториал 4, а не то что можно подумать), т.е. 24. 5 - уже 5!, т.е. 120. И так далее. Так что по здравому размышлению решил оставить вариант формирования имени настройки, и при этом ничего остального не меняется.
Репозиторий болтается в https://github.com/kpblc2000/AppSettingsConsole
зачем это?
public static void ClearSettings()
{
if (File.Exists(_configFileName))
{
File.Delete(_configFileName);
}
}
если файл вдруг только на чтение или по другой причине писать в него нельзя, здесь вылетит исключение