Хранение и получение данных между сессиями в NET

В продолжение настроек под Linux и хранения пользовательских типов данных

А чем не нравятся штатные средства хранения параметров?
Много чем на самом деле.

Если касаться проектов NET Framework, то там да, можно хранить овердофига всякого – и уровня приложения, и уровня пользователя. И еще и разных типов. Проблем две. Первая – найти файл, где хранятся настройки подобных приложений под ACAD, к примеру, может превратиться в тот еще квест с неоднозначным результатом. Вторая – без некоторых танцев с бубнами нарисовать документирование параметра нереально.

Если же говорить про NET6+, то, насколько я помню, там можно хранить только общие типы данных (строка / число / …). Пользовательское перечисление, к примеру, уже не загонишь.

Прежде всего: я ковырялся с AppSettings и ConfigurationManager. Возможно, я не умею их готовить, но как-то они "не зашли". Да и самому было интересно поиграться.

Вопрос номер раз - где и как хранить настройки? В реестре? Серьезно? Учитывая движение в сторону Linux? Так что реестр отпадает. Остается вариант хранения в файле. И вот тут ситуация уже не настолько прозрачна, как хотелось бы. Требований к хранению не так уж и много:

  1. Все должно работать без диких тормозов
  2. Настройки должны быть человекочитабельны

Вариантов хранения, получается, на самом деле не так уж и много: 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();
        }
    }
}

Запуск... Ура, оно запустилось, и даже не вывалило ошибку!
2025-01-19_14-31-39

И даже файл 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
public static string GetDefaultPrinterName(CadEnvironment Env)
{
    throw new NotImplementedException();
}

public static void SetDefaultPrinterName(CadEnvironment Env, string Value)
{
    throw new NotImplementedException();
}

И тут вопрос - а каким манером-то хранить? Можно создать многоуровневый 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

Размещено в .NET, Новости, Разное · Метки:



Комментарии

Есть 1 комментарий к “Хранение и получение данных между сессиями в NET”
  1. drz пишет:

    зачем это?

    public static void ClearSettings()
    {
    if (File.Exists(_configFileName))
    {
    File.Delete(_configFileName);
    }
    }

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

Поделитесь своим мнением


Я не робот.