Домашняя страница Создание системы конфигурирования приложения в C#. Паттерн "первокласный ключ".
Публикация
Отменить

Создание системы конфигурирования приложения в C#. Паттерн "первокласный ключ".

Существует множество способов сохранения данных приложения для их повторного использования при следующем старте. Какой именно вы выберите (хранение данных в базе, в файле конфигурации, в реестре и т.п) не имеет значения. Это зависит от специфики приложения. Однако остается вопрос - как с этим работать программисту в коде. Работа должна быть максимально простой и надежной. В этой статье будут показаны типичные решения и проблемы с ними связанные, а так же будет представлено решение на основе паттерна “Первокласный ключ”. Предложенный подход значительно эффективнее, функциональнее и хорошо масштабируем. Он пригоден для модульных приложений, которые могут распространяться в различных конфигурациях. Подход, который он использует пригоден для решения многих задач. Обо всем этом смотри под катом.

Способы решения задачи:

Существует разные способы. Например написать класс Config - обертку над системой хранения настроек. Каждый элемент (ключ) хранится в виде строки и доступен по строковому идентификатору, поэтому нужны две функции GetS( String id ) и SetS( String id, Int32 val ) для получения и сохранения значения ключа. Для работы с разными типами данных (int, float..) удобно добавить свои специфичные методы GetI, SetI, GetF, SetF, которые принимают идентификатор данных и возвращают его значение. Класс Config сам позаботится о том, чтобы сохраненные данные были доступны при следующем старте приложения. Вот пример интерфейса такой системы:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Config
{
    public static String GetS( String id );
    public static void SetS( String id, Int32 val );

    public static Int32 GetI( String id );
    public static void SetI( String id, Int32 val );

    public static Single GetF( String id );
    public static void SetF( String id, Single val );
       ...
}

// Использование:
const String KEY_NAME = "my_int_value";
Config.SetI( KEY_NAME, 10 ); // сохранение данных
Int32 val1 = Config.GetI( KEY_NAME ); // чтение данных
Single val2 = Config.GetF( KEY_NAME ); // потенциальная ошибка (рассинхронизация типа)
Int32 val3 = Config.GetI( "my_int_value" ); // использование самой строки сильно затруднит поддержку кода (рассинхронизация идентификатора)

Такой подход содержит ряд существенных проблем:

  1. Для добавления нового типа нужно модифицировать этот класс. Это иногда бывает невозможно, когда хочется работать со сложными типами, которые определены в другом месте кода.
  2. Нужно хранить идентификатор. Можно легко его перепутать или случайно модифицировать.
  3. Нужно всегда помнить о типе элемента в двух местах, в месте сохранения и в месте записи. При смене типа параметра нужно рефакторить весь код, причем компилятор в этом не поможет.

Первую проблему можно решить сделав функции GetXXX/SetXXX свободными. Тогда для нового типа нужно будет всего лишь реализовать эти две новые функции и можно уже с этим работать. Однако в реальности захочется добавить для каждого ключа еще значение по умолчанию, диапазоны допустимых значений. Так же может потребоваться сделать визуальное окно редактирования ключа. Все это приведет к разрастанию функциональности, которую будет все сложнее поддерживать и увеличению количества потенциальных ошибок.

Паттерн “первокласный ключ” (first-class key):

Всех этих недостатков лишен паттерн “первокласный ключ” (first-class key). Именно его я и использовал для решения этой задачи. При подобном подходе ключ становится самостоятельной сущностью, а хранилище всего лишь вспомогательным объектом предоставляющем данные.

При данном подходе, для каждого типа данных создается свой класс, который будет предоставлять функциональность по работе с данным типом и полностью изолирует связь с хранилищем данных. На примере целочисленного числа, создадим тип IntCfgKey, который будет предоставлять функциональность по работе с целочисленным ключом. Теперь если нужно где-то в коде завести переменную целого типа, чтобы она автоматически сохранялась и восстанавливалась при следующем старте программы нужно написать следующее:

1
2
3
4
5
IntCfgKey myVar = new IntCfgKey( "my_int_value" ); // создаем переменную и указываем ей идентификатор.

// при использовании не нужно знать идентификатор или хранилище данных.
myVar.Value = 10; // сохранение данных в ключ
Int32 var = myVar.Value; // получение данных из ключа

Как видите, теперь в любой точке кода можно создавать переменные конфигурации, и работать с ними как с обычными переменными. Не нужно нигде писать функций Save/Load, переменные всегда будут содержать актуальное значение и сохранятся между загрузками приложения. Поскольку для каждого типа будет создан свой класс, то он может содержать всю дополнительную функциональность в виде проверки допустимых значений или свою форму по редактированию объекта. Такой подход избавляет программиста от потенциальных ошибок. Теперь программист не использует идентификатор в каждом месте, где он хочет получить доступ к переменной и невозможна ситуация рассинхронизации типов при чтении и записи этой переменной.

Имея такую систему можно создать окно настроек приложения, в котором централизованно редактировать эти переменные. При этом не придется что то дописывать в местах использования переменных. Такое решение очень хорошо масштабируется. Если нужно добавить новый тип, то достаточно просто его реализовать в любом месте кода и начать использовать. Не придется модифицировать саму систему или окно настроек приложения.

Детали реализации

Для начала рассмотрим код целочисленного ключа. Любой ключ наследуется от класса ConfigKey, который предоставляет две функции GetS и SetS получения и сохранения значения в виде строки. Каждый тип должен уметь сериализовать себя в строку, которая будет сохранена и восстановить из строки:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class IntCfgKey : Config.ConfigKey
{
    private Int32        _value;
    public Int32        Value
    {
        get{ return _value; }
        set{ lock( this ){ _value = value; SetS( _value.ToString() ); } }
    }

    //
    public IntCfgKey( String id ) : this( id, 0 )
    {
    }
    public IntCfgKey( String id, Int32 def ) : base( id )
    {
        Int32.TryParse( GetS(), out _value );
    }
}

Как видим код очень простой. Для всех простых типов код будет идентичным, с разницей только в используемом типе. Однако легко можно создать класс, который будет хранить массивы данных и предоставить интерфейс для удобства работы с ними. Этого не добиться обычными функциями GetXXX/SetXXX.

Код же самой системы будет выглядеть следующим образом:

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
public class Config
{
    #region ConfigKey class
    /// Базовый класс для всех ключей
    public class ConfigKey
    {
        public ConfigKey( String id )
        {
            this.Id = id;
            Config.Register( this.Id, this );
        }


        #region management
        public String        Id            { get; set; }
        protected String    GetS()            { return Config.GetKeyValue( this.Id ); }
        protected void        SetS( String value )    { Config.SetKeyValue( this.Id, value ); }
        #endregion
    }
    #endregion


    #region variables
    private static Dictionary< String, ConfigKey >    _cfgKeys = new Dictionary< String, ConfigKey >();    // список ключей
    private static Dictionary< String, String >m_valueStorage = new Dictionary< String, String >();    // хранилище значений ключей
    #endregion


    #region constructors
    public Config()
    {
    }
    #endregion


    #region management
    /// Инициализация системы
    public static void        Init()
    {
        Load();
    }
        
    /// Завершение работы системы
    public static void        Shutdown()
    {
        Save();
    }
    #endregion


    #region config management
    /// регистрация нового ключа
    private static void        Register( String id, ConfigKey key )
    {
        _cfgKeys.Add( id, key );
    }
        
    /// Получение данных по ключу
    private static String        GetKeyValue( String id )
    {
        String val = String.Empty;
        if( _valueStorage.TryGetValue( id, out val ) )
            return val;
        return String.Empty;
    }
        
    /// Изменение данных по ключу
    private static void        SetKeyValue( String id, String value )
    {
        _valueStorage[ id ] = value;
        //Save();
    }
    #endregion


    #region storage management
    /// Загрузка m_valueStorage из хранилища
    private static void    Load()
    {
        ...
    }
        
    /// Сохранение m_valueStorage в какое-нибудь хранилище
    private static void    Save()
    {
        ...
    }
    #endregion
}

Мы видим, что код системы очень прост. Практически все методы сокрыты от внешнего мира, что увеличивает надежность данных. Для начала работы с системой нужно вызвать функцию Config.Init() при старте приложения, а функцию Config.Shutdown() при его завершении. Сейчас данные будут сохранятся только при закрытии приложения, однако если хочется, чтобы фиксация изменений происходила при смене каждого параметра, то можно раскомментировать вызов функции Save в SetKeyValue. Необходимо загрузить все данные (вызвать функцию Init) перед создание ключей. Если нужна другая логика, то систему не сложно модифицировать.

Сама система работает очень просто. При создании каждого ключа, его конструктор вызовет функцию Config.Register которая регистрирует ключ в системе. Функции GetKeyValue и SetKeyValue - это внутренние функции, которые дают возможность ключам получать и менять их значение.

_cfgKeys - библиотека хранения ключей. _valueStorage - хранилище данных ключей. Вы можете сделать это хранилище самостоятельной сложной системой. К теме статьи это не относится.

В функциях Save и Load нужно сохранять и загружать данные из m_valueStorage в любое удобное для вас хранилище данных. Для примера покажу сохранение данных в текстовый config файл.

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
/// простая загрузка из файла
private void    Load()
{
    String filePath = ...;
    if( !File.Exists( filePath ) )
        return;

    XmlDocument dom = new XmlDocument();
    dom.Load( filePath );

    //
    XmlNode root = dom.FirstChild;
    if( null == root || root.Name != "keys" )
        return;
    foreach( XmlNode child in root.ChildNodes )
        _valueStorage[ child.Attributes[ "id" ].InnerText ] = child.Attributes[ "val" ].InnerText;
}

/// простое сохранение в файл
private void    Save()
{
    String filePath = ...;

    // save file
    using( StreamWriter file = new StreamWriter( filePath ) )
    {
        file.Write( "<keys>\r" );
        foreach( var pair in _valueStorage )
            file.Write( "    <key id='{0}' val='{1}'/>\r", pair.Key, pair.Value );
        file.Write( "</keys>" );
    }
}

Вместо вывода:

Созданная система прекрасно подходит как для маленький, так и для очень больших приложений. Конфигурационные переменные можно создавать в Plug-in библиотеках и все будет корректно работать, как при их наличии, так и при отсутствии. За рамками данной статьи осталась уникальность выбора идентификатора ключа. Мне всегда хватало Assert’а, но можно сформулировать некие правила именования, например автоматически добавлять название сборки к идентификатору (+Assert на имя).

Комментарии

mrdekk, 23 дек. 2011, 18:08

К слову если речь идет о C++ то можно воспользоваться шаблонами. А так — реально полезная вещь. Когда с системой первого типа работаешь очень надоедает писать ConfigSystem.Instance.GetI а еще бывает непонятно — GetI — это Int16, Int32, Int64…?

Публикация защищена лицензией CC BY 4.0 .

Графические технологии в компьютерных играх

Существует ли биосфера на Марса? Возможно, под поверхностью!