Динамическое аспектно-ориентированное

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

Задача


Разобрать легче на конкретном примере: надо конфигурировать датчики с множеством параметров, но параметры зависят друг от друга. Например, порог срабатывания зависит от типа датчика, модели и чувствительности, а возможные модели зависят от типа датчика и т.д.

В нашем примере возьмем только тип датчика и его значение (порог, при котором он должен срабатывать).

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;
	   }
	}
}

Одна зависимость порождает большое количество кода, который достаточно трудно читается. Изменения условий или добавления новых зависимостей производить сложно.

image

В реальном проекте, у подобных объектов у нас было более 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; }
}

Осталось заставить это работать. Сам по себе такой класс просто бесполезен.

Диспетчер


Идея проста — закрыть сеттеры и менять значения полей через некий диспетчер, который и будет разбираться во всех правилах, следить за их исполнением, оповещать об изменении полей и логировать все изменения.

image

Вариант рабочий, но код будет выглядеть ужасно:

someDispatcher.Set(mySensor, "Type", SensorType.Voltage);

Можно конечно сделать диспетчер неотъемлемой частью объектов с зависимостями:

mySensor.Set("Type", SensorType.Voltage)

Но мои объекты будут использоваться другими разработчиками и им порой будет не совсем понятно, с какого почему так необходимо писать. Ведь хочется писать просто:

mySensor.Type=SensorType.Voltage;

Наследование


Конкретно в нашей модели мы сами управляли жизненным циклом объектов с зависимостями — мы создавали их только в самой модели и наружу предоставляли только их редактирование. Поэтому сделаем все поля виртуальными, внешний интерфейс модели оставим без изменений, но работать она уже будет с классами «обертками», которые и будут реализовывать логику проверок.

image

Для внешнего пользователя это идеальный вариант, он будет работать с таким объектом привычным способом

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
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 24

    0
    Кат бы повыше…
      +2
      Только синтаксис C# не позволит писать так в аттрибутах.

      Как это не позволяет?!


      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; }
      }
      
      public enum SensorType
      {
          Temperature,
          Voltage
      }
      
      [AttributeUsage(AttributeTargets.All, AllowMultiple = true)]
      class NumberAttribute : Attribute
      {
          public SensorType Type { get; set; }
          public int Min { get; set; }
          public int Max { get; set; }
          public int Force { get; set; }
      }
        0

        От чего зависит свойство мы не сможем задать. Конкретно тут это “Type” — это свойство динамическое. Зависеть свойство может от любого другого поля в классе, а в другом классе вообще другие поля.А атрибут должен работать с разными классами. Это в JavaScript можно задавать то, что не объявлено. А тут вы не сможете в атрибуте прописать все поля от которых может зависить свойство. Вторая проблема, почему числа прописывается в строках — нельзя в атрибутах пользоваться decimal.

          +2
          Вы недостаточно хорошо описали задачу, раз требуются дополнительные пояснения.
            0
            Едва ли для аналоговых датчиков точность представления double перестанет Вас устраивать.
              0
              Хоть про decimal и написанно, что хорошо подходит для финансовых расчетов, для любых других подсчетов которые помещаются на дисплее датчика использовать необходимо только decimal. Пример:
                  for (int i = 0; i < 10000; i++)
                  {
                      sumDouble += 0.001;
                      sumDecimal += 0.001m;
                  }
              

              Так вот в double сумма не будет равна 10, а decimal будет.
              А вот что это за 0.001 может быть:
              • моментальное потребление в 1 литр, и мы считаем потребление за почти три часа(10000 сек) и 10 кубометров не получается
              • это может быть шаг в поле принимающем значение от 0 до 10 в пользовательском интерфейсе, и при каком-то из нажатий стрелочки вверх вместо числа с тремя знаками после запятой, мы получим кучу девяток в хвосте

              Примеров можно придумать много. Но все что можно или нужно представлять в десятичных дробях необходимо считать в decimal.
                +1
                Эти ограничения довольно легко обходятся. Decimal — это большое целое и смещение десятичной запятой. При остром желании парой (long;int) можно описать всё что надо.

                PS: У Вас в примере всё равно только целые числа фигурировали…
          0
          Только синтаксис C# не позволит писать так в аттрибутах.

          Эммм… Кто Вам такое сказал? Именованные свойства называется.
            0

            Не получится прописать все свойства от которых можно строить зависимости, это необходимо прописывать гибко. Строки подходят для описания зависимостей

              +2
              Больше похоже на вопрос архитектуры, но не технические ограничения.
              Валидация формата строк в атрибутах — унос потенциальных ошибок в рантайм.
            0
            «Головная боль при разработке и поддержке» — хорошее описание вашего финального велосипеда. У вас не возникало мысли, что где-то перемудрили, когда всё это изобретали? Задача верификации — не сложная, разжеванная и вполне решаема не write-only способом.
              0
              Вот в том то и дело, что с точки зрения того, кто будет пользоваться такой моделью, все очень просто. Поправил атрибут или повесил новый — и оно само работает. А вот как именно это работает наружу не вылезает. Ну как минимум за пол года никого не волновало. А вот предметная логика (то как зависят свойства друг от друга) собраны в одном месте и читабельны.
                +1
                Но рано или поздно любой код приходится либо поддерживать, либо выкидывать. В данном случае второе выйдет дешевле.
              +2

              Забавно, что статья называется "Динамическое аспектно-ориентированное", но что именно?

                +2
                Для решаемой задачи идеально подходит язык Ada, ещё можно добавить использование SPARK (типы-подмножества, игры с точностью, контрактное программирование, доказательства правильности, ...).
                В этой статье люди описывают создание своих велосипедов на C#. Ну ок, так тоже можно.
                Вроде как с помощью Roslyn можно дополнить C# нужными вещами?
                  +1

                  Более того, для .net существует язык F# в котором есть Units & Union types.

                  0
                  Почему не использовать готовые AOP реализации?
                    0
                    Можно написать конечный автомат, используя при этом шаблон проектирования «Состояние».
                      0
                      Мне кажется или все трудности автора происходят от недостаточной проработанности предметной области? Разработка самодостаточных типов данных обычно приводит к значительному упрощению алгоритмов.

                      Например, можно было ввести типы Voltage и Temperature, ограничения на значения ввести внутри них, а в класс Sensor добавить аргумент типа Sensor.
                        0
                        В том то и дело, что предметная область не ложится на обычную обьектно-ориентированную модель.
                        image
                        Если ввести типы (Voltage и Temperature), то не плохо было бы еще унаследовать от них датчики конкретных производителей (например «Huaweo» и «Deck», где перые имеют подстройку, а вторые нет — Adjustment==0), потом унаследовать от них линейки датчиков(«Common» и «Flex» где вторые могут не просто измерять, а вычислять средние показатели за настроенный интервал — Interval!=0) — получатся классы типа VoltageDeckFlex в котором можно прописать конкретные модели с их возможными параметрами. При этом выяснится что у всех датчиков Flex по стандарту одинаковые ограничения для температурных и для напряжения, а классов VoltageFlex и TemperatureFlex нет и прописывать придется во всех классах (VoltageDeckFlex, VoltageHuaweoFlex, TemperatureDeckFlex, TemperatureHuaweoFlex)
                        С описанием же через атрибуты такой проблемы не возникнет
                        public class Sensor
                        {
                            public virtual SensorType Type { get; set; }
                        
                            public virtual CompanyType Company{ get; set; }
                        
                            public virtual ModelType Model{ get; set; }
                        
                            [Number("Company=Huaweo", "0")]
                            [Number("Company=Deck", "-10..10", Force = "0")]
                            public virtual decimal Adjustment{ get; set; }
                        
                            [Number("", "0")]
                            [Number("Model=Flex", "0..100", Force = "0")]
                            public virtual decimal Interval{ get; set; }
                        
                            [Number("Type=Temperature", "200..600")]
                            [Number("Type=Voltage", "-400..400")]    
                            [Number("Type=Voltage;Company=Deck;Model=Flex", "-600..600")]
                            public virtual decimal Value { get; set; }
                        }
                        

                        Вторая проблема — если пользователь заполнил настройку, а потом выяснил что модель у него другая — надо класс менять.
                          0
                          Вы хотите сказать, что параметры физических элементов хардкодятся, а не выносятся в настройки?..
                            0
                            Абсолютно согласен, могли бы, и реализовывались легко бы. Но в конкретно моем случае, это вещи которые нельзя выдавать пользователям. А если описывать во внутренних ресурсах, то не нашлось причин разделять класс и его ограничения.
                            Да и нагружать статью поставкой правил из конфигов, думаю лишнее — и так довольно тяжелая статья вышла
                            0
                            В том то и дело, что предметная область не ложится на обычную обьектно-ориентированную модель.
                            Предметная область не ложится на обычную обьектно-ориентированную модель для C#, если взять более мощный язык в области ООП, то будет легче.
                          0

                          А что, более вменяемо прямо совсем никак?


                          Ну, например


                          public sealed class Sensor {
                              public static Sensor Voltage() => new Sensor(SensorType.Voltage, -400, 400);
                              public static Sensor Temperature() => new Sensor(SensorType.Temperature, 200, 600);
                          
                              Sensor(SensorType type, decimal min, decimal max) {
                                  Type = type;
                                  this.min = min;
                                  this.max = max;
                              }
                              readonly decimal min;
                              readonly decimal max;
                              public SensorType Type { get; }
                              decimal value;
                              public decimal Value {
                                  get { return value; }
                                  set {
                                      this.value = Max(Min(value, max), min);
                                  }
                              }
                          }

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое