Рассказ про специфичную предметную модель, где многие допустимые значения полей зависят от значений других.
Разобрать легче на конкретном примере: надо конфигурировать датчики с множеством параметров, но параметры зависят друг от друга. Например, порог срабатывания зависит от типа датчика, модели и чувствительности, а возможные модели зависят от типа датчика и т.д.
В нашем примере возьмем только тип датчика и его значение (порог, при котором он должен срабатывать).
Сделать так, чтобы для датчиков напряжения и температуры значения могли быть только в диапазонах -400..400 и 200..600 соответственно. Все изменения можно отслеживать и логировать.
Самое простая реализация для поддержания консистентности данных — это вручную прописать в сеттерах и геттерах ограничения и зависимости:
Одна зависимость порождает большое количество кода, который достаточно трудно читается. Изменения условий или добавления новых зависимостей производить сложно.
В реальном проекте, у подобных объектов у нас было более 30 зависимых полей и более 200 правил на каждый. Описанное решение хоть и рабочее, но принесло бы огромную головную боль при разработке и поддержке такой системы.
Правила легко описываются в коротких формах и их можно разместить рядом с полями, к которым они относятся. В идеале:
Force — это то, какое значение установить, если изменится условие.
Только синтаксис C# не позволит писать так в атрибутах, поскольку список полей от которых зависит целевое свойство не определен заранее.
Мы будем записывать правила следующим образом:
Осталось заставить это работать. Сам по себе такой класс просто бесполезен.
Идея проста — закрыть сеттеры и менять значения полей через некий диспетчер, который и будет разбираться во всех правилах, следить за их исполнением, оповещать об изменении полей и логировать все изменения.
Вариант рабочий, но код будет выглядеть ужасно:
Можно конечно сделать диспетчер неотъемлемой частью объектов с зависимостями:
Но мои объекты будут использоваться другими разработчиками и им порой будет не совсем понятно, с какого почему так необходимо писать. Ведь хочется писать просто:
Конкретно в нашей модели мы сами управляли жизненным циклом объектов с зависимостями — мы создавали их только в самой модели и наружу предоставляли только их редактирование. Поэтому сделаем все поля виртуальными, внешний интерфейс модели оставим без изменений, но работать она уже будет с классами «обертками», которые и будут реализовывать логику проверок.
Для внешнего пользователя это идеальный вариант, он будет работать с таким объектом привычным способом
Осталось научиться создавать такие врапперы
На самом деле есть два способа генерации:
Сгенерировать код, на основе атрибутов — это безусловно круто и работать будет быстро. Но сколько это потребует сил. А, самое главное, если потребуется добавить новые ограничения/правила, сколько потребуется изменений и какой сложности?
Мы будем генерировать стандартный код для каждого setter, который будет вызывать методы, которые будут анализировать атрибуты и выполнять проверки.
Getter мы оставим без изменений:
Здесь в стек кладется первый параметр, который пришел в наш метод, это ссылка на объект (this). Потом вызывается getter базового класса и возвращается результат, который кладется на вершину стека. Т.е. наш getter просто пробрасывает вызов к базовому классу.
С setter немного сложнее. Для анализа мы создадим статический метод, который и будет производить анализ примерно следующим способом:
StrongValidate — будет отбрасывать значения, которые невозможно преобразовать в те, что подходят под правила. Например, в текстовое поле разрешено писать только «y» и «n»; при попытке записать «щ» стоит просто отвергнуть изменения, чтобы модель не была разрушена.
SoftValidate — будет преобразовывать значения из неподходящих в валидные. Например, int поле может принимать только цифры. При попытке записать 111, можно преобразовывать значение к ближайшему подходящему — «9».
<вызов базового сеттера с value> — после того как мы получили валидное значение необходимо вызвать сеттер базового класса, чтобы изменить значение поля.
ForceValidate — после изменения мы можем получить невалидную модель в тех полях, что зависят от нашего поля. Например, изменение Type приводит к изменению Value.
Log — это просто нотификация и логирование.
Чтобы вызвать такой метод нам нужен сам объект, его новое и старое значение, и поле, которое меняется. Код такого сеттера будет выглядеть так:
Нам понадобится еще один метод, который будет непосредственно изменять значение базового класса. Код аналогичен простому getter, только тут два параметра — this и value:
Весь код с небольшими тестами можно найти тут:
github.com/wolf-off/DinamicAspect
Коды самих валидаций просты — они просто ищут текущий активный атрибут по принципу самого длинного условия и спрашивают у него валидно ли новое значение. Стоит только учитывать две вещи при выборе правил (их парсинге и вычислении подходящих):
В чем же преимущество этого подхода?
А в том, что после создания объекта:
Им можно пользоваться как обычным, но вести он себя будет согласно атрибутам:
Задача
Разобрать легче на конкретном примере: надо конфигурировать датчики с множеством параметров, но параметры зависят друг от друга. Например, порог срабатывания зависит от типа датчика, модели и чувствительности, а возможные модели зависят от типа датчика и т.д.
В нашем примере возьмем только тип датчика и его значение (порог, при котором он должен срабатывать).
public class Sensor
{
// Voltage, Temperature
public SensorType Type { get; internal set; }
//-400..400 for Voltage, 200..600 for Temperature
public decimal Value { get; internal set; }
}
Сделать так, чтобы для датчиков напряжения и температуры значения могли быть только в диапазонах -400..400 и 200..600 соответственно. Все изменения можно отслеживать и логировать.
«Простое» решение
Самое простая реализация для поддержания консистентности данных — это вручную прописать в сеттерах и геттерах ограничения и зависимости:
public class Sensor
{
private SensorType _type;
private decimal _value;
public SensorType Type
{
get { return _type; }
set
{
_type = value;
if (value == SensorType.Temperature) Value = 273;
if (value == SensorType.Voltage) Value = 0;
}
}
public decimal Value
{
get { return _value; }
set
{
if (Type == SensorType.Temperature && value >= 200 && value <= 600
|| Type == SensorType.Voltage && value >= -400 && value <= 400)
_value = value;
}
}
}
Одна зависимость порождает большое количество кода, который достаточно трудно читается. Изменения условий или добавления новых зависимостей производить сложно.
В реальном проекте, у подобных объектов у нас было более 30 зависимых полей и более 200 правил на каждый. Описанное решение хоть и рабочее, но принесло бы огромную головную боль при разработке и поддержке такой системы.
«Идеальное», но нереальное
Правила легко описываются в коротких формах и их можно разместить рядом с полями, к которым они относятся. В идеале:
public class Sensor
{
public SensorType Type { get; set; }
[Number(Type = SensorType.Temperature, Min = 200, Max = 600, Force = 273)]
[Number(Type = SensorType.Voltage, Min = -400, Max = 400, Force = 0)]
public decimal Value { get; set; }
}
Force — это то, какое значение установить, если изменится условие.
Только синтаксис C# не позволит писать так в атрибутах, поскольку список полей от которых зависит целевое свойство не определен заранее.
Работающий подход
Мы будем записывать правила следующим образом:
public class Sensor
{
public SensorType Type { get; set; }
[Number("Type=Temperature", "200..600", Force = "273")]
[Number("Type=Voltage", "-400..400", Force = "0")]
public decimal Value { get; set; }
}
Осталось заставить это работать. Сам по себе такой класс просто бесполезен.
Диспетчер
Идея проста — закрыть сеттеры и менять значения полей через некий диспетчер, который и будет разбираться во всех правилах, следить за их исполнением, оповещать об изменении полей и логировать все изменения.
Вариант рабочий, но код будет выглядеть ужасно:
someDispatcher.Set(mySensor, "Type", SensorType.Voltage);
Можно конечно сделать диспетчер неотъемлемой частью объектов с зависимостями:
mySensor.Set("Type", SensorType.Voltage)
Но мои объекты будут использоваться другими разработчиками и им порой будет не совсем понятно,
mySensor.Type=SensorType.Voltage;
Наследование
Конкретно в нашей модели мы сами управляли жизненным циклом объектов с зависимостями — мы создавали их только в самой модели и наружу предоставляли только их редактирование. Поэтому сделаем все поля виртуальными, внешний интерфейс модели оставим без изменений, но работать она уже будет с классами «обертками», которые и будут реализовывать логику проверок.
Для внешнего пользователя это идеальный вариант, он будет работать с таким объектом привычным способом
mySensor.Type=SensorType.Voltage
Осталось научиться создавать такие врапперы
Генерация классов
На самом деле есть два способа генерации:
- Генерировать полноценный код на основе атрибутов
- Генерировать полноценный код, который вызывает проверку атрибутов
Сгенерировать код, на основе атрибутов — это безусловно круто и работать будет быстро. Но сколько это потребует сил. А, самое главное, если потребуется добавить новые ограничения/правила, сколько потребуется изменений и какой сложности?
Мы будем генерировать стандартный код для каждого setter, который будет вызывать методы, которые будут анализировать атрибуты и выполнять проверки.
Getter мы оставим без изменений:
MethodBuilder getPropMthdBldr = typeBuilder.DefineMethod("get_" + property.Name,
MethodAttributes.Public |
MethodAttributes.SpecialName |
MethodAttributes.HideBySig |
MethodAttributes.Virtual,
property.PropertyType, Type.EmptyTypes);
ILGenerator getIl = getPropMthdBldr.GetILGenerator();
getIl.Emit(OpCodes.Ldarg_0);
getIl.Emit(OpCodes.Call, property.GetMethod);
getIl.Emit(OpCodes.Ret);
Здесь в стек кладется первый параметр, который пришел в наш метод, это ссылка на объект (this). Потом вызывается getter базового класса и возвращается результат, который кладется на вершину стека. Т.е. наш getter просто пробрасывает вызов к базовому классу.
С setter немного сложнее. Для анализа мы создадим статический метод, который и будет производить анализ примерно следующим способом:
if (StrongValidate(this, property, value))
{
value = SoftValidate(this, property, value);
if (oldValue != value)
{
<вызов базового сеттера с value>;
ForceValidate(baseModel, property);
Log(baseModel, property, value, oldValue);
}
}
StrongValidate — будет отбрасывать значения, которые невозможно преобразовать в те, что подходят под правила. Например, в текстовое поле разрешено писать только «y» и «n»; при попытке записать «щ» стоит просто отвергнуть изменения, чтобы модель не была разрушена.
[String("", "y, n")]
SoftValidate — будет преобразовывать значения из неподходящих в валидные. Например, int поле может принимать только цифры. При попытке записать 111, можно преобразовывать значение к ближайшему подходящему — «9».
[Number("", "0..9")]
<вызов базового сеттера с value> — после того как мы получили валидное значение необходимо вызвать сеттер базового класса, чтобы изменить значение поля.
ForceValidate — после изменения мы можем получить невалидную модель в тех полях, что зависят от нашего поля. Например, изменение Type приводит к изменению Value.
Log — это просто нотификация и логирование.
Чтобы вызвать такой метод нам нужен сам объект, его новое и старое значение, и поле, которое меняется. Код такого сеттера будет выглядеть так:
MethodBuilder setPropMthdBldr = typeBuilder.DefineMethod("set_" + property.Name,
MethodAttributes.Public |
MethodAttributes.SpecialName |
MethodAttributes.HideBySig |
MethodAttributes.Virtual,
null, new[] { property.PropertyType });
//получаем статический метод, который будем вызывать
var setter = typeof(DinamicWrapper).GetMethod("Setter", BindingFlags.Static | BindingFlags.Public);
ILGenerator setIl = setPropMthdBldr.GetILGenerator();
setIl.Emit(OpCodes.Ldarg_0);//кладем в стек первый параметр - this
setIl.Emit(OpCodes.Ldarg_1);//кладем в стек второй параметр - у setter это новое значение (value)
if (property.PropertyType.IsValueType) //необходимо сделать Boxing, если это тип Value, потому что статический метод будет принимать на вход object
{
setIl.Emit(OpCodes.Box, property.PropertyType);
}
setIl.Emit(OpCodes.Ldstr, property.Name); //положить в стек имя метода
setIl.Emit(OpCodes.Call, setter);
setIl.Emit(OpCodes.Ret);
Нам понадобится еще один метод, который будет непосредственно изменять значение базового класса. Код аналогичен простому getter, только тут два параметра — this и value:
MethodBuilder setPureMthdBldr = typeBuilder.DefineMethod("set_Pure_" + property.Name,
MethodAttributes.Public, CallingConventions.Standard, null, new[] { property.PropertyType });
ILGenerator setPureIl = setPureMthdBldr.GetILGenerator();
setPureIl.Emit(OpCodes.Ldarg_0);
setPureIl.Emit(OpCodes.Ldarg_1);
setPureIl.Emit(OpCodes.Call, property.GetSetMethod());
setPureIl.Emit(OpCodes.Ret);
Весь код с небольшими тестами можно найти тут:
github.com/wolf-off/DinamicAspect
Валидации
Коды самих валидаций просты — они просто ищут текущий активный атрибут по принципу самого длинного условия и спрашивают у него валидно ли новое значение. Стоит только учитывать две вещи при выборе правил (их парсинге и вычислении подходящих):
- Кешировать результат GetCustomAttributes. Функция которая берет аттрибуты у полей работает медленно потому, что она их каждый раз создает. Кешируйте её результат. Мной реализованно в базовом классе BaseModel
- При вычислении подходящих правил придется разбираться с типами полей. Если все значения приводить к строкам и сравнивать — будет медленно работать. Особенно enum. Реализованно в базовом классе аттрибутов DependencyAttribute
Заключение
В чем же преимущество этого подхода?
А в том, что после создания объекта:
var target = DinamicWrapper.Create<Sensor>();
Им можно пользоваться как обычным, но вести он себя будет согласно атрибутам:
target.Type = SensorType.Temperature;
target.Value=300;
Assert.AreEqual(target.Value, 300); // true
target.Value=3;
Assert.AreEqual(target.Value, 200); // true - minimum
target.Value=3000;
Assert.AreEqual(target.Value, 600); // true - maximum
target.Type = SensorType.Voltage;
Assert.AreEqual(target.Value, 0); // true - minimum
target.Value= 3000;
Assert.AreEqual(target.Value, 400); // true - maximum