Русский
Русский
English
Статистика
Реклама

Паттерны

Пишите код по-новому (тм)

15.04.2021 16:11:54 | Автор: admin


C# я не люблю, но люблю собирать все паттерны и весь сахар, который они предлагают от версии к версии.

Третьего дня посмотрел выступление Билла Вагнера на NDC Conferences, где он показывал, что нужно писать код по-новому (TM).

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

Сахаром делу не поможешь


Возьмем плохо написанный фрагмент кода, который написал любитель на коленке. Этот метод проверяет состояние экземпляра класса и возвращает true, если все хорошо и false, если не хорошо.

internal bool GetAvailability(){    if (_runspace.RunspacePoolAvailability == RunspacePoolAvailability.Available) { return true;}    if (_runspace.RunspacePoolAvailability == RunspacePoolAvailability.Busy) { return true;}    return false;}

Программист старался, даже ни одного else в методе. Но мы то опытные, давайте зарефакторим его, уберем ifы и превратим это в тернарку:

internal bool GetAvailability(){    return _runspace.RunspacePoolAvailability == RunspacePoolAvailability.Available ||           _runspace.RunspacePoolAvailability == RunspacePoolAvailability.Busy;}

Стало гораздо лучше, 2 строки кода вместо 5, но тернарку можно превратить в паттерн:

internal bool GetAvailability(){    return _runspace.RunspacePoolAvailability is RunspacePoolAvailability.Available or RunspacePoolAvailability.Busy;}

Итого мы оставили одну красивую строчку кода. Все! Рефакторинг завершен! (нет)

internal void Invoke(){if (!GetAvailability()) return;        PowerShell _powershell = PowerShell.Create();        _powershell.RunspacePool = _runspace;        _powershell.Invoke()    }

Вызов _powershellа в недоступном _runspacee вызовет исключение.

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

Код был не современен, но его смысл не поменялся.

Больше исключений!


Когда программа сталкивается с реальностью, конечно, возникают исключительные ситуации. Файл не найден, файл не того формата, не того содержимого, содержимого нет, Streamreader прочитал null или пустую строку передал это дальше. Написаны две строки кода и обе сломаны, но я посмотрел выступление и прозрел.

Но беспокойтесь, теперь, делая свой собственный класс или библиотеку вы можете не думать о защитном коде, тайпчек за нас делает компилятор, а проверка на null никогда не была проще!

Просто скиньте все на пользователя библиотеки и пишите код. Выкидывать исключения и класть программу теперь стало престижно! Я кидаю, а ты лови!

Тот, как я понял доклад Билла Вагнера NDC Conferences 2020

Я был настолько вдохновлен этой концепцией, да и работой .net в целом, поэтому расскажу вам правдивую историю разработки классов RunspacePool и Powershell из System.Management.Automation, с которыми я недавно столкнулся:

Курильщик 1 делает Powershell


Первым делом, конечно, чтобы отслеживать, состояние мы делаем булёвое поле, которое изменяется на true при вызове метода Dispose.

Показывать поле IsDisposed в принципе небезопасно, потому что, если CLR соберет мусор, можно словить Null reference.

class PowershellInNutshell() : IDisposable{    private static bool IsDisposed = false;    private static RunspacePoolInTheNuttshell;    public static void Invoke()    {        if (IsDisposed) throw new ObjectDisposedException();        Console.WriteLine("I was invoked");    }    public void Dispose()    {        if (IsDisposed) throw new ObjectDisposedException("Invoke","Сообщение на русском языке, если винда русская или на итальянском, если итальянская");        IsDisposed = true;        Console.WriteLine("I was invoked");        GC.SuppressFinalize(this);    }}

При повторном вызове Dispose или другого метода мы выкидываем исключение и пусть другой программист еще и своим кодом отслеживает состояние экземпляра или ловит исключения, но это не мои проблемы.

Курильщик 2 делает RunspacePooll


Тут тоже делаем поле IsDisposed, но на этот раз делаем его с публичным гетером, чтобы человеку, использующему библиотеку не пришлось писать больше защитного кода.

class RunspacePoolInTheNuttshell() : IDisposable{    public static bool IsDisposed = false;        public void Dispose()    {        if (IsDisposed) return;        IsDisposed = true;        GC.SuppressFinalize(this);        Console.WriteLine("I was invoked");    }}

Если метод Dispose был вызван, делаем return и дело с концом. Конечно, при повторном обращении к полю он получит nullref, потому что объект уже будет удален из памяти, но моя ли это проблема.

Здоровый человек использует библиотеку:


Здесь исключение, здесь не исключение, здесь рыбу заворачиваем. Оба класса идут в одном пакете и имеют различное поведение. Классы выбрасывают один и тот же тип исключений по разным причинам.

  • Неправильный пароль? InvalidRunspacePoolStateException!
  • Нет соединения? InvalidRunspacePoolStateException!

Получается, что в одном месте нужно обработать ObjectDisposedException, в другом NullReferenceException в третьем InvalidRunspacePoolStateException и все полно сюрпризов.

Исключение не решение



До причащения святых таинств я читал файл по-старому:

public static void Main(){    string txt = @"c:\temp\test.txt";        if (File.Exists(txt)) return;    string readText = File.ReadAllText(txt);    Console.WriteLine(readText);}

Но после просмотра видео я начал делать по-новому:

public static void Main(){    string txt = @"c:\temp\test.txt";    try    {        string readText = File.ReadAllText(txt);        Console.WriteLine(readText);    }    catch (System.IO.FileNotFoundException)    {        Console.WriteLine("File was not found");    }}

Или это по-новому?

public static void Main(){    string txt = @"c:\temp\test.txt";    if (!File.Exists(txt))    {        throw new NullReferenceException();    }    string readText = File.ReadAllText(txt);    Console.WriteLine(readText);}

Как именно по-новому? Где именно кончается ответственность разработчика и начинается ответственность пользователя?


В целом, задумка ясна, если ты являешься автором библиотеки, то можно сразу вызывать метод и подавать на него некорректные данные, ты прекрасно знаешь предметную область и описал все случаи неправильного поведения, выбросил исключения, которые имеют смысл и сам их обработал.

internal class NewWay{    public static string _a;    public static string _b;    public static string _c;    public static void NewWay(string a, string b, string c)    {        string _a = a ?? throw new NullReferenceException("a is null");        string _b = b ?? throw new NullReferenceException("b is null");        string _c = c ?? throw new NullReferenceException("c is null");    }    public void Print()    {        if (String.Compare(_a, _b) != 0)        {            throw new DataException("Some Other Ex");        }        Console.WriteLine($"{_a + _b + _c}");// Логика    }}

try{    NewWay newway = new(stringThatCanBeNull, stringThatCanBeNull, stringThatCanBeNull);    newway.Print();}catch (NullReferenceException ex){    Console.WriteLine("Компенсаторная логика");}catch (DataException ex){    Console.WriteLine("Компенсаторная логика");}

Самые догадливые уже поняли, к чему я веду. Организация коррекции ошибок построенная на try catch блоках приведет только к углублению вложенности кода.

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

В целом, ничего нового, еще 10 лет назад люди начали подозревать что С# перегружен паттернами и из года в год их меньше не становится. Встречайте еще один, бросать исключения стало еще проще.

И под конец операторы



Операторы не должны ничего никуда кастить.

Пример из JS вы наверняка знаете:

console.log('2'+'2'-'2');// 20

Дизайнеры JS посчитали, что отдельный оператор сложения и отдельный оператор конкатенации не нужны, поэтому делать математику на JS небезопасно.

Источником этого бага в JS является неявное приведение типа string к типу int посредством оператора. Так лишний сахар становится багом.

Неявным кастом типов болеет и C# тоже, хоть и гораздо реже. Взять, например, пользовательский инпут, который после обновления библиотеки начал мапиться в string вместо int, как раньше, а оператор (+) и математический оператор и оператор конкатенации.

Изменение типа с int на string код не сломало, а бизнес-логику сломало.

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

class Program{    static void Main(string[] args)    {        Console.WriteLine($"{'2' + '2' - '2' }");    }}

Подробнее..

Техники повторного использования кода

08.03.2021 12:14:02 | Автор: admin

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

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

Т.к. в последние годы я пишу на JavaScript и React, то они будут использоваться в некоторых примерах. Да и в целом, периодически я буду упоминать React и другие веб-технологии. Тем не менее, думаю, что значительная часть статьи должна быть понятна и полезна разработчикам из других стеков.

Для некоторых подходов я добавил схемы, чтобы показать, как организованы составляющие сложных объектов. Будет часто упоминаться агрегация (агрегирование/делегирование/включение) и композиция.

Описывается то, как я это понял, поэтому где-то информация может быть ошибочной.

Чтобы разделить логику одного сложного объекта на составные части, существуют несколько механизмов:

  • Разделение функционала на классы/объекты и смешивание их полей, методов в одном объекте.

  • Вынесение части функционала в обертки и помещение в них основного объекта, либо вкладывание объектов один в другой с организацией списка вложенных объектов.

  • Вынесение части функционала в отдельные объекты/функции и помещение их в основной объект.

  • Разделение функционала объекта на независимые части и использование какого-то внешнего механизма для организации нужного поведения с использованием этих частей.

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

1) Объединение (смешивание) функционала нескольких объектов в одном
Смешивание и примеси (миксины)
Классическое наследование
Множественное наследование и интерфейсы

2) Композиция/агрегация с использованием списка
Прототипное наследование
Паттерн декоратор и аналоги

3) Композиция/агрегация с использованием одноуровневых структур данных (ссылка, массив ссылок, словарь)
Паттерн стратегия
Entity Component (EC)

4) Композиция/агрегация с вынесением логики вне объекта и его составляющих
Entity Component System (ECS)

5) Композиция/агрегация с использованием графов
Паттерн State machine

6)Композиция/агрегация с использованием деревьев
Паттерн composite и другие древовидные структуры
Behaviour tree

7) Смешанные подходы
React hooks

Объединение (смешивание) функционала нескольких объектов в одном.

Смешивание и примеси (миксины)

Самый простой, но ненадежный способ повторного использования кода объединить один объект с другим(и). Подходит лишь для простых случаев, т.к. высока вероятность ошибки из-за замещения одних полей другими с такими же именами. К тому же, так объект разрастается и может превратиться в антипаттерн God Object.

Существует паттерн примесь (mixin/миксина), в основе которого лежит смешивание.
Примесь это объект, поля и методы которого смешиваются с полями и методами других объектов, расширяя функциональность объекта, но который не используется сам по себе.
Можно добавить несколько миксин к одному объекту/классу. Тогда это схоже с множественным наследованием.

Классическое наследование

Здесь описывается классическое наследование, а не то, как наследование классов устроено в JS.

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

При наследовании происходит копирование членов родительского класса в класс-наследник. При создании экземпляра класса тоже происходит копирования членов класса. Я не исследовал детали этих механизмов, к тому же они явно отличаются в различных языках. Подробнее с этой темой можно ознакомиться в 4-ой главе книги "Вы не знаете JS:thisи Прототипы Объектов".

Когда можно использовать наследование, а когда не стоит?
Наследования не стоит использовать в качестве основной техники для повторного использования кода для сложных объектов. Его можно использовать совместно с композицией для наследования отдельных частей сложного объекта, но не для самого сложного объекта. Например, для React компонентов наследование плохо, а для частей (вроде объектных аналогов custom hooks) из которых мог быть состоять компонент-класс, наследования вполне можно использовать. Но даже так, в первую очередь стоит рассматривать разбиение на большее число составляющих или применения других техник, вместо наследования.

При возможности появления сложной иерархии наследование (более 2-х уровней, где первый уровень иерархии родитель, а второй уровень - наследники) тоже не следует использовать наследование.

Множественное наследование и интерфейсы

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

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

Интерфейсы следует понимать не как наследование, а как контракт, указывающий, что данный класс реализует такой-то интерфейс. Плохо, когда один класс реализует слишком много интерфейсов. Это означает, что-либо интерфейсы слишком сильно разбиты на части, либо у объекта слишком большая ответственность.

Композиция/агрегация с использованием списка

Прототипное наследование

При прототипном наследовании уже не происходит смешивания родительского объекта и его наследника. Вместо этого наследник ссылается на родительский объект (прототип).

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

Стоит отметить, что в JavaScript операции записи/удаления работают непосредственно с объектом. Они не используют прототип (если это обычное свойство, а не сеттер). Если в объекте нет свойства для записи, то создается новое. Подробнее об этом: https://learn.javascript.ru/prototype-inheritance#operatsiya-zapisi-ne-ispolzuet-prototip

Цепочка прототипов организована как стек (Last-In-First-Out или LIFO). Какой объект добавлен в цепочку последним (если считать от итогового объекта-контейнера), к тому обращение будет первым.

Также существует вариант, когда при создании нового объекта с прототипом, создается копия прототипа. В таком случае используется больше памяти (хотя это проблема разрешаема), но зато это позволяет избежать ошибок в других объектах из-за изменения прототипа конкретного объекта.

Паттерн Декоратор и аналоги

Декоратор (wrapper/обертка) позволяет динамически добавлять объекту новую функциональность, помещая его в объект-обертку. Обычно объект оборачивается одним декоратором, но иногда используется несколько декораторов и получается своего рода цепочка декораторов.

Цепочка декораторов устроена как стек(LIFO). Какой объект добавлен в цепочку последним (если считать от итогового объекта-контейнера), к тому обращение будет первым.

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

Как в случае с прототипами, зачастую можно подменять декораторы во время выполнения. Декоратор оборачивает только один объект. Если оборачивается несколько объектов, то это уже что-то другое.

HOF (higher order function) и HOC (Higher-Order Component) - паттерны с похожей идей. Они оборачивают функцию/компонент другой функцией/компонентом для расширения функционала.

HOF - функция, принимающая в качестве аргументов другие функции или возвращающая другую функцию в качестве результата. Примером HOF в JS является функция bind, которая, не меняя переданную функцию, возвращает новую функцию с привязанным к ней с помощью замыкания значением. Другим примером HOF является карринг.

HOC - чистая функция, которая возвращает другой компонент (а он уже содержит в себе произвольную логику), который внутри себя "рендерит" переданный компонент. При этом сам переданный компонент не меняется, но в него могут быть переданы props.

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

const funcA = сompose(funcB, funcC, funcD);

или же менее читабельный вариант:

const funcA = ()=> {  funcB( funcC( funcD() ) ) ;};

То же самое можно получить такой записью:

function funcA() {  function funcB() {      function funcC() {         function funcD()      }    }}  

Недостатком последнего варианта является жесткая структура функций. Нельзя поменять их очередность или заменить одну из функций без создания новой аналогичной цепочки или ее части. funcC нельзя использовать без funcD, а funcB без funcC и без funcD. В первых же двух примерах можно. Там функции независимы друг от друга.

Итого

Прототипное наследование и использование декораторов гибче, чем подходы со смешиванием.

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

Когда по аналогии с иерархией наследования используется несколько уровней вложения одних объектов в другие, получается иерархия вложенных объектов. Почти то же самое, что и наследование, только с возможностью подменять объекты в иерархии. Конечно, в случае декораторов этого обычно избегают и вместо иерархии получается цепочка. В цепочке декораторов выходит так, что каждый следующий используемый декоратор помимо своих членов классов должен реализовать члены всех остальных объектов в цепочке. В итоге, по аналогии с наследованием, снова получается раздутый объект с множеством полей и методов. На схеме выше был пример такого объекта - DecoratorC.

Зачастую при использовании нескольких декораторов на одном объекте не добавляют новые поля и методы, а лишь подменяют реализацию уже существующих членов объекта. Остается другой недостаток из-за большой вложенности довольно сложно разобраться, что же делает итоговый составной объект, т.к. для этого надо пройтись по цепочке вложенных объектов.

Как видите, по-прежнему остаются довольно серьезные проблемы. Но, есть другие решения, о которых рассказано в следующих главах.

Композиция/агрегация с использованием одноуровневых структур данных (ссылка, массив ссылок, словарь)

Под одноуровневой структурой данных я подразумеваю структуру, элементы которой не ссылаются на другие элементы.

Паттерн стратегия

Паттерны декоратор и стратегия служат для одной цели с помощью делегирования расширить функциональность объекта. Но делают они это по разному. Хорошо описана эта разница по ссылке - https://refactoring.guru/ru/design-patterns/strategy:
Стратегияменяет поведение объекта изнутри, аДекораторизменяет его снаружи.

Паттерн Cтратегия описывает разные способы произвести одно и то же действие, позволяя динамически заменять эти способы в основном объекте (контексте).

На схеме ниже пара примеров связи стратегий с основным объектом.

К похожим способам (использование ссылки) расширения функционала объекта и повторного использования кода можно отнести события в HTML элементах и директивы в Angular и Vue.

<button onclick="customAction()" /> // html<input v-focus v-my-directive="someValue" /> // vue

Entity Component (EC)

Я не знаю, как называется данный паттерн. В книге Game Programming Patterns он называется просто "Компонент", а по ссылке http://entity-systems.wikidot.com/ его называют системой компонентов/сущностей. В статье же я буду называть его Entity Component (EС), чтобы не путать с подходом, который будет описан в следующей главе.

Сначала пройдемся по определением:

  • Entity (сущность) объект-контейнер, состоящий из компонентов c данными и логикой. В React и Vue аналогом Entity является компонент. В Entity не пишут пользовательскую логику. Для пользовательской логики используются компоненты. Компоненты могут храниться в динамическом массиве или словаре.

  • Component объект со своими данными и логикой, который можно добавлять в любую Entity. В React компонентах похожим аналогом являются custom hooks. И описываемые здесь компоненты и пользовательские хуки в React служат для одной цели расширять функционал объекта, частью которого они являются.

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

Данный паттерн похож на паттерн стратегия. Если в объекте использовать динамический массив со стратегиями, организовать их добавление, удаление и получение определенной стратегии, то это будет похоже на Entity Component. Есть еще одно серьезное отличие - контейнер не реализует интерфейс компонентов или методы для обращения к методам компонентов. Контейнер только предоставляет доступ к компонентам и хранит их. Получается составной объект, который довольно своеобразно делегирует весь свой функционал вложенным объектом, на которые он ссылается. Тем самым EC избавляет от необходимости использования сложных иерархий объектов.

Плюсы EC

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

  • легко добавлять новую функциональность и использовать код повторно.

  • можно изменять составной объект (Entity) в процессе выполнения, добавляя или удаляя его составляющие (компоненты)

Минусы

  • для простых проектов является ненужным усложнением из-за разбиение объекта на контейнер и компоненты

В одной из своих следующих статей я опишу применение этого подхода для React компонентов. Тем самым я покажу, как избавиться от первых двух недостатков компонентов на классах, описанных в документации React-а:
https://ru.reactjs.org/docs/hooks-intro.html#its-hard-to-reuse-stateful-logic-between-components
https://ru.reactjs.org/docs/hooks-intro.html#complex-components-become-hard-to-understand

Этот подход используется с самого начала выхода движка Unity3D для расширения функционала элементов (объектов) дерева сцены, включая UI элементы, где вы можете получше ознакомится с данным подходом: https://docs.unity3d.com/ru/2019.4/Manual/UsingComponents.html. Но в таком случае придёться потратить не мало времени на изучение движка.

Итого

Паттерн стратегия сам по себе не очень мощный, но если развить его идею, можно получить довольно эффективные техники. К такой можно отнести Entity Component.

В случае использования EC может появиться новая проблема при большом количестве компонентов, связанных между собой в одном объекте, становиться сложно разобраться в его работе. Выходом может стать некий компонент, который контролирует взаимодействия между компонентами в одной Entity или в группе вложенных Entities. Такой подход известен как паттерн Посредник (Mediator).

Но даже посредника будет недостаточно для более сложных случаев. К тому же он не является универсальным. Для каждой Entity с множеством связанных компонентов придёться реализовывать новый тип посредника. Есть и другой выход. EC можно комбинировать с другими подходами на основе графов и деревьев, которые будут описаны позже.

Композиция/агрегация с вынесением логики вне объекта и его составляющих

Entity Component System (ECS)

Я не работал с этим подходом, но опишу то, как я его понял.

В ECS объект разбивается на 3 типа составляющих: сущность, компонент (один или несколько), система (общая для произвольного числа объектов). Этот подход похож на EC, но объект разбивается уже на 3 типа составляющих, а компонент содержит только данные.

Определения:

  • Entity его основное назначение, это идентифицировать объект в системе. Зачастую Entity является просто числовым идентификатором, с которым сопоставляется список связанных с ним компонентов. В других вариациях Entity также может брать на себя роль контейнера для компонентов. Как и в EC подходе, в Entity нельзя писать пользовательский код, только добавлять компоненты.

  • Component - объект с определенными данными для Entity. Не содержит логики.

  • System - в каждой системе описывается логика. Каждая система перебирает список компонентов определенных типов или компоненты определенных entities и выполняет логику с использованием данных в компонентах. Может извлекать компоненты из entities. Результатом выполнения системы будет обновление данных в компонентах. В некоторых случаях системы могут быть обычными функциями, получающими на вход нужные данные.

Также может быть некий объект-менеджер (или несколько менеджеров), который хранит все системы и объекты, а также периодически запускает все системы. Здесь уже реализация произвольная.

Пример простой ECS: Допустим есть несколько объектов, у которых есть идентификаторы. Несколько из этих объектов ссылаются на компоненты Position, в которых хранятся текущие координаты x, y, и на компонент Speed, который содержит текущую скорость. Есть система Movement, которая перебирает объекты, извлекает из них компоненты Position и Speed, вычисляет новую позицию и сохраняет новые значения x, y в компонент Position.

Как я уже говорил, реализации ECS могут отличаться. Например:

a) entity является контейнером для своих компонентов
http://entity-systems.wikidot.com/artemis-entity-system-framework

b) компоненты содержится в массивах/словарях. Entity является просто идентификатором, по которому определяется компонент, связанный с сущностью.
http://jmonkeyengine.ru/wiki/jme3/contributions/entitysystem/introduction-2
http://entity-systems.wikidot.com/fast-entity-component-system#java
https://www.chris-granger.com/2012/12/11/anatomy-of-a-knockout/

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

Плюсы ECS

  • Слабое сцепление составляющих объекта, поэтому легко добавлять новую функциональность комбинирую по-разному составляющие.

  • Проще тестировать, т.к. нужно тестировать только системы. Компоненты и сущности тестировать не нужно.

  • Легко выполнять многопоточно.

  • Более эффективное использование памяти, кэша и, следовательно, большая производительность.

  • Легко реализовать сохранение всего приложения, т.к. данные отделены от функционала.

Минусы ECS

  • Высокая сложность, не стандартный подход.

  • для простых проектов является ненужным усложнением.

Так как я занимаюсь фронтенд разработкой, а она по большей части относится к разработки UI, то упомяну, что ECS используется в игре WorldofTanksBlitz для разработки UI:
https://www.youtube.com/watch?v=nu8JJEJtsVE

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

Как и в аналогичном случае с EС, системы в данном подходе можно комбинировать с подходами на основе графов и деревьев. В таком случае логика по-прежнему будет реализована в системах, а хранение данных в компонентах. Но, я не знаю, эффективно ли совмещение этих подходов на практике. В первую очередь нужно стремиться реализовывать системы попроще, а уже потом рассматривать комбинацию с другими подходами.

Композиция/агрегация с использованием графов

К данному способу повторного использования кода я отнес паттерн машина состояний (State machine/Finite state machine/конечный автомат).

Аналогом машины состояний простой является switch:

switсh (condition) { case stateA: actionA(); case stateB: actionB(); case stateC: actionC();}

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

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

Также существуют иерархические машины состояний, где состояние может содержать вложенный граф состояний.

Я уже описывал паттерн Машина состояний и его составляющие, и вкратце писал о иерархической машине состояний в статье "Приемы при проектировании архитектуры игр"в главе "машина состояний".

Преимущества использования машины состояний:
Хорошо описано по ссылке: https://refactoring.guru/ru/design-patterns/state
Добавлю, что становится легче предусмотреть, обработать и протестировать все возможные случаи работы контекста (подсистемы), т.к. видны все его состояния и переходы. Особенно, если состояния являются просто объектами с данными и отделены от остальной логики и отображения.

Где при разработке UI можно использовать машину состояний?
Например, для логики сложной формы, у которой поведение и набор полей немного отличается в зависимости от роли пользователя и других параметров. Каждый объект-состояние может описывать состояние всех компонентов формы (активный, видимый, фиксированный текст элемента в таком-то состоянии и т.д.), отображение которых может отличаться в зависимости от роли пользователя и других параметров. Компоненты формы получают часть объекта-состояния, которая им нужна для своего отображения.

Другие примеры использования в UI:
https://24ways.org/2018/state-machines-in-user-interfaces/
https://xstate.js.org/docs/ (библиотека для JS, которую можно использовать c React, Vue, Svelte)
https://github.com/MicheleBertoli/react-automata (библиотека для React)
http://personeltest.ru/aways/habr.com/ru/company/ruvds/blog/346908/

Подходит ли State machine в качестве основного механизма повторного использования кода и разбиения сложных объектов на составные части?
Иногда он так и используется. Но, он мне кажется сложноватым и не всегда подходящим для использования в качестве основного. Зато он точно хорош в качестве дополнительного, когда нужно организовать взаимодействия между несколькими объектами или частями составного объекта.

Стоит учитывать, что граф может получиться слишком сложным. Если у вас обычный код получается запутанным, то и граф скорее всего получится запутанным. В сложном графе нужно стремиться уменьшать количество связей, группировать состояния.

Композиция/агрегация с использованием деревьев.

Паттерн composite и другие древовидные структуры

Деревья часто встречается в разработке. Например, объекты в JavaScript могут содержать вложенные объекты, а те также могут содержать другие вложенные объекты, тем самым образую дерево. XML, JSON, HTML, DOM-дерево, паттерн Комповщик (Composite) все это примеры древовидной композиции.

Дерево является графом, в котором между любыми 2-мя его узлами может быть только один путь. Благодаря этому, в нем гораздо проще разобраться, чем в графе получаемом с помощью машине состояний.

Behaviour tree

Интересным вариантом композиции является Behaviour tree (дерево поведения). Это организация логики программы (обычно AI) или ее частей в виде дерева.

В дереве поведения в качестве узлов выступают управляющие блоки - условие, цикл, последовательность действий, параллельноевыполнение действий и блоки действий. В теории, могут быть реализованы и другие управляющие блоки, вроде switch case, асинхронного блока и других аналогов управляющих конструкций языка, но я не встречал подобного. Код действий для деревьев поведения пишет разработчик. Обычно решения для деревьев поведений содержат визуальный редактор для их создания и отладки.

Я уже описывал деревья поведений в прошлом в этой статье.

Более наглядный пример схемы готового дерева из плагина banana-tree

Дерево поведения можно рассматривать как аналог обычной функции с вложенными функциями. Я думаю, понятно, что схему выше можно перевести в функцию с вложенными функциями, условиями и циклами.

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

Деревья поведения позволяют создавать сложную логику с помощью комбинации более простых действий. Сложная логика тоже может быть оформлена в виде узла дерева (в виде действия или поддерева). Также деревья поведения предоставляют единый механизм для разработки в таком стиле. Этот подход мотивирует выносить функционал в отдельные настраиваемые объекты, зачастую предоставляет наглядное средство для отладки, упорядочивает и уменьшает связи между объектами, позволяет избежать жесткой зависимости составляющих.

Для простых случаев, как обычно, этот подход будет ненужным усложнением.

Смешанные подходы

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

Довольно многое можно отнести к смешанных подходам. Entity Component в Unity3D реализован так, что позволяет хранить не только компоненты, но и вложенные сущности. А для пользовательских компонентов можно использовать наследование в простых случаях, либо объединить компоненты с более продвинутыми техниками (паттерн mediator, машина состояний, дерево поведения и другие).

Примером смешивания подходов является анимационная система Mecanim в Unity3D, которая использует иерархическую машину состояний с деревьями смешивания (blend tree) для анимаций. Это относится не совсем к коду, но является хорошим примером комбинации подходов.

К смешанным подходам я отнес React компоненты с хуками, т.к. там довольно специфичный случай. С точки зрения разработчика, для повторного использования кода в компоненте используется дерево функций (хуков). С точки зрения организации данных в компоненте компонент хранит список с данными для хуков. К тому же связь между хуками и компонентом устанавливается во время выполнения. Т.к. я разрабатываю на React, то решил включить частичное описание внутреннего устройство компонента с хуками в статью.

React hooks

Эта часть статьи для разработчиков, знакомых c React. Остальным многое в ней будет не понятно.

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

Как я понял, хуки при вызове добавляют к текущему обрабатываемому компоненту (точнее к fiber-ноде) свое состояние объект, в котором могут быть указаны переданные сallback-и (в случае useEffect, useCallback), массив зависимостей, значения (в случае useState) и прочие данные (в случае useMemo, useRef, ).

А вызываются хуки при обходе дерева компонентов, т.е. когда вызывается функция-компонент. React-у известно, какой компонент он обходит в данный момент, поэтому при вызове функции-хука в компоненте, состояние хука добавляется (или обновляется при повторных вызовах) в очередь состояний хуков fiber-ноды. Fiber-нода это внутреннее представление компонента.

Стоит отметить, что дерево fiber элементов не совсем соответствует структуре дерева компонентов. У Fiber-ноды только одна дочерняя нода, на которую указывает ссылка child. Вместо ссылки на вторую ноду, первая нода ссылается на вторую (соседнюю) с помощью ссылки sibling. К тому же, все дочерние ноды ссылаются на родительскую ноду с помощью ссылки return.

Также для оптимизации вызова эффектов (обновление DOM, другие сайд-эффекты) в fiber-нодах используются 2 ссылки (firstEffect, nextEffect), указывающие на первую fiber-ноду с эффектом и следующую ноду, у которой есть эффект. Таким образом, получается список нод с эффектами. Ноды без эффектов в нем отсутствуют. Подробнее об этом можно почитать по ссылкам в конце главы.

Вернемся к хукам. Структура сложного функционального компонента с несколькими вложенными custom hooks для разработчика выглядит как дерево функций. Но React хранит в памяти хуки компонента не как дерево, а как очередь. На схеме ниже изображен компонент с вложенными хукам, а под ним fiber-нода с очередью состояний этих же хуков.

На схеме в fiber-ноде отображены также поля, которые участвует в создании различных структур для оптимизации рендеринга. Они не будут рассмотрены в рамках статьи.

Чтобы просмотреть содержимое fiber-ноды, достаточно воспользоваться console.log и вставить туда JSX код, который возвращает компонент:

function MyComponent() {  const jsxContent = (<div/>);  console.log(jsxContent);  return jsxContent;}

Корневую fiber-ноду можно просмотреть следующим образом:

const rootElement = document.getElementById('root');ReactDOM.render(<App />, rootElement);console.log(rootElement._reactRootContainer._internalRoot);

Также есть интересная наработка: react-fiber-traverse

Под спойлером приведен код компонента с хуками и отображение его fiber-ноды
import { useState, useContext, useEffect,useMemo, useCallback,         useRef, createContext } from 'react';import ReactDOM from 'react-dom';const ContextExample = createContext('');function ChildComponent() {  useState('childComponentValue');  return <div />;}function useMyHook() {  return useState('valueB');}function ParentComponent() {  const [valueA, setValueA] = useState('valueA');  useEffect(function myEffect() {}, [valueA]);  useMemo(() => 'memoized ' + valueA, [valueA]);  useCallback(function myCallback() {}, [valueA]);  useRef('refValue');  useContext(ContextExample);  useMyHook();  const jsxContent = (    <div>      <ChildComponent />      <button onClick={() => setValueA('valueA new')}>Update valueA</button>    </div>  );  console.log('component under the hood: ', jsxContent);  return jsxContent;}const rootElement = document.getElementById('root');ReactDOM.render(  <ContextExample.Provider value={'contextValue'}>    <ParentComponent />  </ContextExample.Provider>,  rootElement,);

С более подробным описанием работы внутренних механизмов React на русском языке можно ознакомиться по ссылкам:
Как Fiber в React использует связанный список для обхода дерева компонентов
Fiber изнутри: подробный обзор нового алгоритма согласования в React
Как происходит обновление свойств и состояния в React подробное объяснение
За кулисами системы React hooks
Видео: Под капотом React hooks

У подхода с хуками на данный момент есть недостаток - фиксированное дерево функций в компонентах. При стандартном использовании хуков, нельзя изменить логику уже написанного компонента или хуков, состоящих из других хуков. К тому же это мешает тестированию хуков по отдельности. В какой-то степени можно улучшить ситуацию композицией (compose) хуков. Например, существует такое решение: https://github.com/helloitsjoe/react-hooks-compose

Линейность кода и составляющих сложного объекта

Известно, что множество вложенные условий, callback-ов затрудняют читаемость кода: https://refactoring.guru/ru/replace-nested-conditional-with-guard-clauses
http://personeltest.ru/aways/habr.com/ru/company/oleg-bunin/blog/433326/ (в статье упоминается линейный код)
https://www.azoft.ru/blog/clean-code/ (в статье упоминается линейность кода)

Я думаю, что наследование, большие цепочки и иерархии вложенных объектов могут привести к аналогичной ситуации, но для составляющих сложного объекта. Даже если объект расположен линейно в коде, внутри он может быть устроен так, что необходимо пройтись по множеству родительских классов или вложенных объектов, чтобы разобраться в его функционале. Я уже писал ранее про деревья поведения, что в них следует избегать большой вложенности. Так и в других случаях.

Подробнее..

Про Patterns

24.06.2020 06:06:01 | Автор: admin


Меня часто спрашивают, когда ты в первый раз познакомился с паттернами? И в очередной раз этот вопрос оставляет меня в небольшом замешательстве. Могу ли я сказать, что это был тот самый знойный июльский день, когда я ехал в маршрутке по томному пути решив взять интересную книжку по программированию в дорогу? Книжкой, кстати, оказалась та самая Gang of Four, Design Patterns. Или может это произошло немного раньше, когда я только появился на свет, и начал впитывать в себя разноцветный свет и голоса этого мира? Может еще раньше, когда, сидя в утробе матери, мой генетический алгоритм в очередной раз отрабатывал программу, которая по большому счету, уже была до меня отработана на моих родителях и прародителях множество поколений назад. Не могу сказать точно, возможно в одном из этих сюжетов. Достоверно могу сказать, что книжка эта меня чем-то зацепила. Я стал думать, что я думаю паттернами.
Disclaimer после прочтения текста под хабракатом, вам может стать скучно жить. Автор не хотел бы нести груз ответственности за ваше дальнейшее существование.


За время работы программистом я успел поработать на проектах для самых разных рынков. На каждом из них я старался увидеть те самые последовательности в доменной модели, либо в потоках данных, которые в конце концов сводятся к одной, уже давно кем-то решенной задаче с теми или иными преимуществами или недостатками. Такие места находились довольно часто, и это позволяло видеть немного дальше, чем текущие требования, быть готовым к расширяемости, или изменению функционала ранее, чем они появлялись. Bridge, Composite, Adapter и прочие, все это находило применение в той или иной ситуации. Но одна мысль все же не оставляла вашего покорного слугу, и не давала возможности спокойно насладиться текущим положением дел, а именно вопрос: а почему собственно так? Что это за магические комбинации такие, и почему они не используются повсеместно? Возможно ли это как-то применить в других отраслях или областях жизни? Всегда казалось, что здесь спрятано нечто большее
Для того, чтобы разобраться, мне пришлось отправиться на самое дно. Ну как дно, это наверное прозвучало несколько метафорично, потому что, и я надеюсь, читатель со мной согласится, никакого дна нет. Тем не менее, кривая изысканий привела меня в мир, близкий к нейробиологии. Ведь, если разобраться, все что нас окружает, тем или иным образом обрабатывается именно мозгом, и наше восприятие происходящего реализуется именно там. Конечно, на сегодняшний день, 2020-го года никто досконально не разобрался, как оно работает и по каким принципам эти 86 миллиардов нейронов пересылая друг с другу электрические сигналы формируют внутреннее представление того, что являет собой наш разум. Но это 2020-й, удивительно, как мы вообще всё еще выживаем. Ни смотря ни на что, есть позитивные сдвиги и, например, Jeff Hawkins в книге On Intelligence представил свою модель на суд зрителя еще в далеком 2004-м, которая многим пришлась по душе и которой, я хотел бы с вами поделиться.

Тут нужно сделать небольшую литературную сноску в основные определения и понятия. Итак, каждый нейрон в мозге состоит из тела и отростков (дендритов и аксонов). Те, места, где отростки 2-х нейронов соприкасаются, позволяя проходить электрическому сигналу называются синапсами. Таким образом, нейроны имеют возможность посылать сигнал, или общаться, с теми нейронами, с которыми у них есть соединение, т.е. синапс. Если по простому, если у нас есть есть физическое соединение, между нейроном, который отвечает за книгу и нейроном, который отвечает за автора, то мы знаем и то и другое, ну а если нету, то просто понятия не имеем, кто написал эту книжку.
Особый интерес для нас представляет такое понятие, как автоассоциативная память механизм, когда определенная последовательность сигналов передается от одного нейрона к другому и затем, эта же самая последовательность возвращается от нейрона получателя, к нейрону отправителю назад.
Предвосхищаю ваш вскрик негодования, Как так? Дублирование кода? Нафига!? Меня так же зацепил этот кусок функционала. Напоминает ситуацию, когда вы взяли в магазине красивые бананы, подходите на кассу, продавец спрашивает, чем вы будете оплачивать покупку? и вы не долго думая отвечаете, пожалуй, сегодня заплачу бананами!. Deal!

Тут есть одна деталь. Дело в том, что последовательность, которая подается на вход, может быть неполной, или битой, с погрешностями и неточностями. А в ответ возвращается сигнал такой, какой он должен быть в идеале (по крайней мере с точки зрения той части, которая его принимает). Т.е. вы идете в магазин со своими своими просроченными, черными бананами и платите ими за красивые, зеленые бананы, которые вам заботливо выложил в сумочку продавец. Для чего это все? Одним из ожидаемых предположений является оптимизация. Зачем каждый раз запрашивать всю информацию целиком, если по холодному, черному носу понятно, что перед нами собака? Или по характерным четырем ногам и доске сверху вполне можно определиться, что это ни что иное как обыкновенный стол.
Внимательный читатель наверное догадался, что вся эта прелюдия с паттернами была не просто так. Дело в том, что последовательность сигналов, которую передает один нейрон к другому, это и есть один из подвидов тех самых паттернов. Когда нейрон получатель распознает определенную, известную ему последовательность, он может восстановить полную картину целиком по тем неполным данным, которые поступили к нему на вход. И если я вам сейчас пропою Маленькой елочке.., ваш мозг тут же распознает этот до боли знакомый паттерн и, совершенно верно, закончит ее тем самым }{oloDn0 3NMoI. Если нам нужно задизайнить функционал, в котором определенные операции могут выполняться в виде атомарной транзакции, с возможностью отката и повторения, вы сразу же думаете про Command. Когда мы катаемся на велосипеде или читаем, мозг распознает знакомые паттерны, и в ответ включает знакомую ему программу реагирования на них, например включить определенную моторику в руках и ногах, чтобы не упасть, или распознать слово, которое будет идти следующим, пока мы до него даже не дочитали. Другими словами мозг это машина распознавания паттернов, или, если сказать простым английским языком pattern recognition machine.

Если теперь оценить масштаб фундаментальности данного механизма, те design patterns, к которым мы так привыкли, и которые выручают нас когда нужно, например, задизайнить типичный и гибкий подход к созданию объектов, являются просто каплей в океане событий, происходящих вокруг и которые основаны на этих самых паттернах. Мы почти все делаем по шаблонам, крупным и мелким, временнм и в пространстве, логическим и физическим. Иногда, жизнь представляется мне вот такой:



Т.к. живем мы в мире, на который очень сильно повлиял человеческий разум, нас окружают вещи, которые сделаны по одним и тем же паттернам. Вы ходите на работу и домой в одно время, одним и тем же путем, сериалы, которые вы смотрите написаны по одному сценарию и всегда заканчиваются одинаково, в магазине вы покупаете одни и те же продукты, нравится вам одинаковая музыка, игры в которые вы играете заставляют вас ходить по кругу снова и снова в поисках очередного модного сапога +n к здоровью и т. д. и т. п. Это не похоже на мир существ разумных, которые отвечают за свои действия, это скорее мир муравьев, у которых в бесконечном цикле запущена подпрограмма существования по пути оптимизации и наименьшего сопротивления. Осознание своих шаблонов делает мир довольно унылым и скучным, ведь все хотят быть уникальными и непредсказуемыми в своем роде, а тут такой банальный поворот. Звучит грустновато, но есть просвет. Когда мы вырастаем из одних паттернов, нас начинают увлекать другие, более высокого порядка, а это уже совсем другая история. Поэтому, все же есть надежда, что вы, дорогой читатель, на заскучаете, а найдете себе паттерн, который заменит предыдущий, если вы вдруг решите его поменять. Чтобы посмотреть на механизм паттернов в динамике, попробуйте посмотреть пару серий мультфильма про Чипа и Дейла, если он вам нравился в детстве, вы наверняка заметите подвох, если на данный момент вы уже находитесь во взрослом состоянии.

Ну и основной вопрос. Что с этим делать? Как и все вокруг нас окружающее, паттерны имеют свои плюсы и минусы. Так как жизнь, это в целом social game, люди, которые умеют видеть свойства общества, в котором мы живем, имеют больше шансов использовать их в свою выгоду и получить от жизни то, что им нужно, чем тем, которым не понятно и, из-за этого, недостижимо. Ну и конечно понимание своих собственных свойств и особенностей тоже может сильно помочь. Начиная от общих, принадлежащих виду homo sapiens в целом, и углубляясь в детали, понимание того, чем я конкретно отличаюсь от других sapiens и в каких ситуациях мои личные особенности могут играть мне на пользу, а в каких мешать. Понимание своих паттернов может помочь увидеть себя в будущем, ну или хотя бы в одной из веток квантовой вселенной. Что уже в принципе не плохо, чтобы попытаться что-то изменить, подправить свой курс и увеличить вероятности попадания туда, куда вы хотите попасть, а не туда, куда нас автоматом несет река жизни.

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

Стоп рефакторинг. Kotlin. Android

24.02.2021 00:19:22 | Автор: admin

Введение

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

Я хочу рассказать про практики, которые не один раз уже выручали нас в проекте. Подборка примеров получилась не на пустом месте, все реальные примеры PullRequest-ов.
Все примеры НЕ выдуманные и тестировались на живых людях. В процессе сбора данных несколько людей пострадало.

Заменяйте if-else на when где это необходимо

Долгое время Java был предпочтительным языком программирования для платформы Android. Затем на арену пришел Kotlin, да вот привычки остались старые.

fun getNumberSign(num: Int): String = if (num < 0) {    "negative"} else if (num > 0) {    "positive"} else {    "zero"}

Красиво - 7 строк и получаем результат. Можно проще:

fun getNumberSign(num: Int): String = when {    num < 0 -> "negative"    num > 0 -> "positive"    else -> "zero"}

Тот же код, а строк 5.

Не забываем и про каскадное использованиеif-elseи его нечитабильность при разрастании кодобазы. Если в вашем проекте нет необходимости поддерживать 2 ЯП(Kotlin + Java), настоятельно рекомендую взять его себе на вооружение. Одна из самых популярных причин его игнорирования - "Не привычно"

Дело не в предпочтениях стилистики писания: семистопный дактиль или пятистопный хорей. Дело в том, что в Kotlin отсутствует операторelse-if. Упуская этот момент можно выстрелить себе в ногу. А вот и сам пазлер 9 отАнтона Кекса.

Я не рекомендую использоватьwhenвезде, где только можно. В Kotlin нет(и небудет) тернарного оператора, и стандартные булевы условия стоит использовать по классике. Когда условий больше двух, присмотритесь и сделайте код элегантнее.

Отряд булевых флажков

Рассмотрим следующее на примере поступающего ТЗ в динамике:

1. Пользователь должен иметь возможность видеть доставлено сообщение или нет

data class Message(  // ...  val isDelivered: Boolean)

Все ли здесь хорошо? Будет ли модель устойчива к изменениям? Есть ли гипотетическая возможность того, что в модели типаMessageне будут добавлены новые условия в будущем? Имеем ли мы право считать, что исходные условия ТЗ есть оконченный постулат, который нельзя нарушить?

2. Пользователь должен иметь возможность видеть прочитано сообщение или нет

data class Message(  // ...  val isDelivered: Boolean,  val isRead: Boolean) 

Не успели мы моргнуть глазом, как ProductOwner передумал и внес изменения в первоначальные условия. Неожиданно? Самое простое решение - добавить новое поле и "решить" проблему. Огорчу, не решить - отложить неизбежное. Избавление от проблемы здесь и сейчас - must have каждого IT инженера. Предсказание изменений и делать устойчивую систему - опыт, паттерны, а иногда, искусство.

Под "отложить неизбежное" я подразумеваю факт того, что рано или поздно система станет неустойчива и придет время рефакторинга. Рефакторинг -> дополнительное время на разработку -> затраты не по смете бюджета -> неудовлетворенность заказчика -> увольнение -> депрессия -> невозможность решить финансовый вопрос -> голод -> смерть. Все из-за Boolean флага?!!! COVID-19 не так уж страшен.

Что не так? Сам факт появления изменений не есть глупость PO, который не мог сразу сформулировать свою мысль. Не все то, что очевидно сейчас, было очевидно ранее. Чем меньше время на маневр, тем вы ценнее и конкурентнее. Далее включим фантазию и попробуем предугадать, что же еще может придумать менеджер?

3. Пользователь должен иметь возможность видеть отправлено ли сообщение

4. Пользователь должен иметь возможность видеть появилось ли сообщение в нотификациях e.t.c.

Если мы сложим воедино все новые требования, будет видно, что объектMessageможет находиться только в одном состоянии: отправлено, доставлено, появилось ли сообщение в нотификациях, прочитано Набор состоянийдетерминирован. Опишем их и заложим в наш объект:

data class Message(  // ...  val state: State) {    enum class State {        SENT,        DELIVERED,        SHOWN_IN_NOTIFICATION,        READ    }}

Набор определен. Мы однозначно можем считать, что если сообщение прочитано, то оно отправлено и доставлено Для этого мы можем полагаться на приоритет состояний, забегу вперед, лучше всего переопредлить компаратор, где приоритет будет указан явно. Но можем ли мы утверждать, что оно отображалось в нотификациях? Ведь пользователь может отключить системный показ нотификаций. В случае, когда это будет действительно важно, однозначное состояние не подойдет. В таком случае, мы можем воспользоваться бинарными флагами:

data class Message(  // ...  val states: Set<State>) {  fun hasState(state: State): Boolean = states.contains(state)}// либо data class Message(    // ...    val states: States) {    enum class State(internal val flag: Int) {        SENT(1),        DELIVERED(1 shl 1),        READ(1 shl 2),        SHOWN_IN_NOTIFICATION(1 shl 3)    }    data class States internal constructor(internal val flags: Int) {        init {          check(flags and (flags+1)) { "Expected value: flags=2^n-1" }        }        constructor(vararg states: State): this(            states.map(State::flag).reduce { acc, flag -> acc or flag }        )        fun hasState(state: State): Boolean = (flags and state.flag) == state.flag    }}

Выводы: перед тем как начать проектировать систему, задайте необходимые вопросы, которые помогут вам найти подходящее решение.Можно ли считать набор условий конечным? Не изменится ли он в будущем?Если ответы на эти вопросы ДА-ДА - смело вставляйте булево состояние. Если же хоть на один вопрос ответ НЕТ - заложите детерменированный набор состояний. Если объект в один момент времени может находиться в нескольких состояниях - закладывайте множество.

А теперь посмотрим на решение с булевыми флагами:

data class Message(  //..  val isSent: Boolean,  val isDelivered: Boolean  val isRead: Boolean,  val isShownInNotification: Boolean) //...fun drawStatusIcon(message: Message) {  when {    message.isSent && message.isDelivered && message.isRead && message.isShownInNotification ->     drawNotificationStatusIcon()    message.isSent && message.isDelivered && message.isRead -> drawReadStatusIcon()    message.isSent && message.isDelivered -> drawDeliviredStatusIcon()    else -> drawSentStatus()   }}

Попробуйте добавить еще одно состояние(ошибку) в конец и в середину списка приоритетов. Без чтения документации и без ознакомления работы с флагами это сделать будет проблематично.

Одно состояние

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

data class User(    val username: String?    val hasUsername: Boolean)

По условию контракта есть возможность не заполнить имя пользователя. На GUIне такое состояние должно подсветиться предложением. За состояние предложения, логично считать, переменнуюhasUsername. По объявленным соглашениям, легко допустить простую ошибку.

// OKval user1 = User(username = null, hasUsername = false) // Ошибка, имя пользователя естьval user2 = User(username = "user", hasUsername = false) // OKval user3 = User(username = "user", hasUsername = true) // Ошибка, имя пользователя не задано, а флаг говорит об обратномval user4 = User(username = null, hasUsername = true) // Ошибка, имя пользователя пустое, а флаг говорит об обратномval user5 = User(username = "", hasUsername = true) // Ошибка, имя пользователя пустое, а флаг говорит об обратномval user6 = User(username = " ", hasUsername = true) 

Узкие места в контракте открывают двери для совершения ошибки. Источником ответственности за наличие имени является только одно поле -username.

data class User(    val username: String?) {    fun hasUsername(): Boolean = !username.isNullOrBlank()}

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

  • вычислить сразу либо заленивить состояние

data class User(    val username: String?) {    val hasUsername: Boolean = !username.isNullOrBlank()    val hasUsernameLazy: Boolean by lazy { !username.isNullOrBlank() }}
  • вынести вычисление в утилитарный класс. Используйте только в случае тяжеловесности операции

class UsernameHelper {    private val cache: MutableMap<User, Boolean> = WeakHashMap()        fun hasUsername(user: User): Boolean = cache.getOrPut(user) {       !user.username.isNullOrBlank()     }}

Абстракции - не лишнее

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

Ключи для 3rd party services получаем из backend. Клиент долженсохранитьэти ключи для дальнейшего использования в приложении.

// ...val result = remoteService.getConfig()if (result is Result.Success) {  val remoteConfig = result.value.clientConfig?.keys  for (localConfigKey: ConfigKey in configKeyProvider.getConfigKeys()) {    sharedPreferences.edit { putString(localConfigKey.key, remoteConfig[localConfigKey.key]) }    }}//...enum class ConfigKey(val key) {  FACEBOOK("facebook"),  MAPBOX("mapbox"),  THIRD_PARTY("some_service")}

Спустя N недель получаем предупреждение от службы безопасности, что ключи сервисаTHIRD_PARTYни в коем случае нельзя хранить на диске устройства. Не страшно, можем спокойно хранить ключи хранить InMemory. И по такой же стратегии нужно затронуть еще 20 компонентов приложения. Хм, и как поможет абстракция?

Завернем под абстракцию хранлище ключей и создадим имплементацию: InMemory / SharedPreferences / Database / WeakInMemory А дальше с помощью внедрения зависимостей. Таким образом мы не нарушимSOLID - в нашем примере актором будет являться алгоритм сбора данных, но не способ хранения; open-closed principle достигается тем, что мы "прикрываем" необходимость модификации алгоритма за счет абстракции.

// ...val result = remoteService.getConfig()if (result is Result.Success) {  val remoteConfig = result.value.clientConfig?.keys  for(localConfigKey: ConfigKey in configKeyProvider.getConfigKeys()) {    configurationStorage.put(        configKey = localConfigKey,         keyValue = remoteConfig[localConfigKey.key]      )  }}//....interface ConfigKeyStorage {   fun put(configKey: ConfigKey, keyValue: String?)   fun get(configKey: ConfigKey): String   fun getOrNull(configKey: ConfigKey): String?}internal class InMemoryConfigKeyStorage : ConfigKeyStorage {private val storageMap: MutableMap<ConfigKey, String?> = mutableMapOf()  override fun put(configKey: ConfigKey, keyValue: String?) {    storageMap[configKey] = keyValue}  override fun get(configKey: ConfigKey): String =       requireNotNull(storageMap[configKey])override fun getOrNull(configKey: ConfigKey): String? =       storageMap[configKey]}

Если помните, в изначальной постановке задачи не стояло уточнение о типе хранилища данных. Подготавливаем систему к изменениям, где имплементация может быть различной и никак не влияет на детали алгоритма сбора данных. Даже если в изначальных требованиях и были бы уточнения по типу хранилища - это повод для того, чтобы усомниться и перестраховаться. Вместо того, чтобы влезать в N компонентов для модификации типа хранилища, можно добиться этого с помощью замены источника данных через DI/IoC и быть уверенным, что все работает исправно. Так же, такой код проще тестировать.

Описывайте состояния явно

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

В очередной раз возьмем пример технического задания:

Подготовить репозиторий для вывода имени пользователя на экран

Создадим репозиторий, который будет возвращать имя пользователя. Выведемnullв случае, если не смогли получить имя. Так как в первоначальном задании не шло речи о том, откуда нам нужно брать данные - оставим дело за абстракцией и заодно создадим наивное решение для получения из remote.

interface UsernameRepository {    suspend fun getUsername(): String?}class RemoteUsernameRepository(    private val remoteAPI: RemoteAPI) : UsernameRepository {    override suspend fun getUsername(): String? = try {        remoteAPI.getUsername()    } catch (throwable: Throwable) {        null    }}

Мы создали контракт получения имени пользователя, где в качестве успeшного результата приходит состояниеString?и в случае провала полученияString?. При чтении кода, нет ничего подозрительного. Мы можем определить состояние ошибки простым условиемgetUsername() == nullи все будут счастливы. По факту, мы не имеем состояния провала. По контрактуSuccessState === FailState.

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

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

interface UsernameRepository {    suspend fun getUsername(): String?}class CommonUsernameRepository(  private val remoteRepository: UsernameRepository,  private val localRepository: UsernameRepository) : UsernameRepository {    suspend fun getUsername(): String? {        return remoteRepository.getUsername() ?: localRepository.getUsername()    }}

И вот наступает грустный момент. Теперь по нашему контракту 3 различных состояния при одном и том же результате. Попробуйте ответить на следующие утверждения:

  • верно ли утверждать, что результатnull- имя пользователя? Обязательных условий мы не имеем. Все легально.

  • верно ли утверждать, что результатnull- состояние из кэша?

  • верно ли утверждать, что результатnull- состояние ошибки удаленного узла при пустом кэше?

Однозначного ответа нет, так как наш контракт не подразумевает этого. Да и по требованиям от нас этого не требуют. А зачем нужно явно декларировать состояния для системы, если важен результат? Смежные состояния при одинаковых результатах - русло неустойчивости узла к изменениям. Избегайте неоднозначности. Новые требования, которые затронут хотя бы один смежный статус, потребуют рефакторинга корневой модели. Изменения в начальной абстракции - это очень страшно. Это чревато тем, что вам придется переписать все известные имплементации.

В случае получения ошибки - изменить цвет имени на экране.

Используйтеenum/sealed classes/interfaces/abstract classes. Техника выведения абстракций зависит от изначальных условий проекта. Если вам важна строгость в контрактах и вы хотите закрыть возможность произвольного расширения -enum/sealed classes. В противном случае -interface/abstract classes.

sealed class UsernameState {data class Success(val username: CharSequence?) : UsernameState()  object Failed : UsernameState()}

When может не хватить

Рассмотрим следующий пример - старт приложения, где в зависимости от состояния приложения необходимо выполнить переход на следующий экран.

enum class NavigationFlow {  PIN_CODE,  MAIN_SCREEN,  ONBOARDING,  CHOOSE_LANGUAGE}fun detectNavigationFlow(): NavigationFlow {    return when {        authRepo.isAuthorized() -> NavigationFlow.PIN_CODE        languageRepo.defaultLanguage != null -> NavigationFlow.CHOOSE_LANGUAGE        onboardingStorage.isCompleted() -> NavigationFlow.MAIN_SCREEN        else -> NavigationFlow.ONBOARDING    }}

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

enum class NavigationFlow {    PIN_CODE,    MAIN_SCREEN,    ONBOARDING,    CHOOSE_LANGUAGE}// Описываем возможные состояния явноsealed class State {    data class Found(val flow: NavigationFlow) : State()    object NotFound : State()}interface NavigationFlowProvider {    // Возвращаем не null NavigationFlow чтобы гарантировать проход на следующий экран    fun getNavigation(): NavigationFlow}// Абстракция для поиска подходящего флоу для навигацииinterface NavigationFlowResolver {    fun resolveNavigation(): State}internal class SplashScreenNavigationFlowProvider(    // Sequence - для того чтобы прервать итерации при нахождении первого подходящего условия.    // Обратите внимание на очередность экземляров класса в последовательности.    private val resolvers: Sequence<NavigationFlowResolver>) : NavigationFlowProvider {    override fun getNavigation(): NavigationFlow = resolvers        .map(NavigationFlowResolver::resolveNavigation)        .filterIsInstance<State.Found>()        .firstOrNull()?.flow        // Если ничего не нашли - проход в состояние неизвестности        ?: NavigationFlow.MAIN_SCREEN}

Заменяем N-условныйwhenнаChainOfResponsibililty. На первый взгляд выглядит сложным: кода стало больше и алгоритм чуть сложнее. Перечислим плюсы подхода:

  1. Знакомый паттерн из ООП

  2. Соответствует правилам SOLID

  3. Прост в масштабировании

  4. Прост в тестировании

  5. Компоненты резолвера независимы, что никак не повлияет на структуру разработки

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

Наследование или композиция

Вопрос по этой теме поднимался ни один миллионраз. Я не буду останавливаться на подробностях, детали о проблемах можете почитать на просторах google. Хочу затронуть тему платформы, когда причина избыточного использования наследования - "платформа". Разберем на примерах компонентов Android.

BaseActivity. Заглядывая в старые прокты, с ужасом наблюдаю, какую же ошибку мы допускали. Под маской повторного использования смело добавляли частные случаи в базовую активити. Шли недели, активити обрастали общими прогрессбарами, обработчиками и пр. Проходят месяцы, поступают требования - на экране N прогрессбар должен отличаться от того, что на всех других От общей активити отказаться уже не можем, слишком много она знает и выполняет. Добавить новый прогрессбар как частный случай - выход, но в базовом будет оставаться рудимент и это будет нечестное наследование. Добавить вариацию вBaseActivity- обидеть других наследников и Через время вы получаете монстра в > 1000 строк, цена внесения изменений в который слишком велика. Да и не по SOLID это все.

Агаок, но мне нужно использовать компоненту, которая точно будет на всех экранах кроме 2х. Что делать?

Не проблема, Android SDK еще с 14 версиипредоставили такую возможность.Application.ActivityLifecycleCallbacksоткрывает нам простор на то, чтобы переопределять элементы жизненного цикла любойActivity. Теперь общие случаи можно вынести в обработчик и разгрузить базовый класс.

class App : Application(), KoinComponent {    override fun onCreate() {        super.onCreate()        // ...         registerActivityLifecycleCallbacks(SetupKoinFragmentFactoryCallbacks())    }    // Подключаем Koin FragmentFactory для инициализации фрагментов с помощью Koin    private class SetupKoinFragmentFactoryCallbacks : EmptyActivityLifecycleCallbacks {        override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {            if (activity is FragmentActivity) {                activity.setupKoinFragmentFactory()            }        }    }}

К сожалению, не всегда возможно отказаться от базовой активити. Но можно сделать ее простой и лаконичной:

abstract class BaseActivity(@LayoutRes contentLayoutId: Int = 0) : AppCompatActivity(contentLayoutId) {    // attachBaseContext по умолчанию protected    override fun attachBaseContext(newBase: Context) {        // добавляем extension для изменения языка на лету        super.attachBaseContext(newBase.applySelectedAppLanguage())    }}

BaseFragment. С фрагментами все тоже самое. ИзучаемFragmentManager, добавляемregisterFragmentLifecycleCallbacks- профит. Чтобы проброситьFragmentLifecycleCallbacksдля каждого фрагмента - используйте наработки из предыдущих примеров сActivty. Пример на базе Koin -здесь.

Композиция и фрагменты. Для передачи объектов можем использовать инъекции DIP фреймворков - Dagger, Koin, свое и т.д. А можем отвязаться от фрейморков и передать их в конструктор. ЧТОООО? Типичный вопрос с собеседования - Почему нельзя передавать аргументы в конструктор фрагмента? До5 ноября 2018 года было именно так, теперь же естьFragmentFactoryи это стало легально.

BaseApplication. Здесь чуть сложнее. Для разныхFlavorsиBuildTypeнеобходимо использовать базовыйApplicationдля возможности переопределения компонентов для других сборок. Как правило,Applicationстановится большим, потому что на старте приложения, необходимо проинициализировать большое количество 3rd party библиотек. Добавим к этому и список своих инициализаций и вот мы на пороге того момента, когда нам нужно разгрузить стартовую точку.

interface Bootstrapper {    // KoinComponent - entry point DIP для возможности вызвать инъекции зависимостей в метод     fun init(component: KoinComponent)}interface BootstrapperProvider {    fun provide(): Set<Bootstrapper>}class BootstrapperLauncher(val provider: BootstrapperProvider) {    fun launch(component: KoinComponent) {        provider.provide().onEach { it.init(component) }    }}class App : Application() {  override fun onCreate() {        super.onCreate()        // Вызываем бутстраппер после инициализации Koin        this.get<BootstrapperLauncher>().launch(component = this)    }}

Разгружаем килотонны методов в разныеBootstrapperинстансы и делаем наш код чище. Либо можем воспользоваться нативным решением отзеленого робота.

Уменьшение области видимости

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

Выделим отдельный модуль, который будет содержать в себе объекты валидации входящих состояний.

interface Validator {    fun validate(contact: CharSequence): ValidationResult}sealed class ValidationResult {    object Valid : ValidationResult()    data class Invalid(@StringRes val errorRes: Int) : ValidationResult()}class PhoneNumberValidator : Validator {    override fun validate(contact: CharSequence): ValidationResult =        if (REGEX.matches(contact)) ValidationResult.Valid         else ValidationResult.Invalid(R.string.error)    companion object {        private val REGEX = "[0-9]{16}".toRegex()    }}

А разве плохо иметь публичный класс, который будет доступен всем? Но избыточное использование публичных сущностей по умолчанию означает, что объект данного класса может использоваться каждым. Возникает желание внести изменения для личных нужд не задумываясь о последствиях. Если вы не обезопасились методами, которые не пропустят "сломанный" код в рабочую среду, ждите бага.

Пришло обновление задачи, когда на экране N вместоMSISDNнеобходимо использоватьE.164:

class PhoneNumberValidator : Validator {    override fun validate(contact: CharSequence): ValidationResult =        if (REGEX.matches(contact)) ValidationResult.Valid         else ValidationResult.Invalid(R.string.error)    companion object {        private val REGEX = "+[0-9]{16}".toRegex()    }}

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

С одной стороны, проблема надуманная и обойти ее можно было:

  • создать новый валидатор

  • создать валидатор с регексом по умолчанию и передать аргумент для частного случая

  • наследование и переопределение

  • другой подход

А теперь, давайте посмотрим на код, если бы мы изначально забетонировали MSISDN валидатор и вынесли бы его в бинарь.

interface Validator {    fun validate(contact: CharSequence): ValidationResult}sealed class ValidationResult {    object Valid : ValidationResult()    data class Invalid(@StringRes val errorRes: Int) : ValidationResult()}internal class MSISDNNumberValidator : Validator {//... код выше}internal class E164NumberValidator : Validator {//... код выше}

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

interface ValidatorFactory {    fun create(type: ValidatorType): Validator?    interface ValidatorType    companion object {        fun create() : ValidatorFactory {            return DefaultValidatorFactory()        }    }}object MSISDN : ValidatorFactory.ValidatorTypeobject E164 : ValidatorFactory.ValidatorTypeprivate class DefaultValidatorFactory : ValidatorFactory {    override fun create(type: ValidatorFactory.ValidatorType): Validator? = when(type) {        is MSISDN -> MSISDNValidator()        is E164 -> E164Validator()        else -> null    }}

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

Заключение

В общем случае, при проектировании систем, я руководствуюсь правилам SOLID. Про эти принципы говорят не первый десяток лет из каждого утюга, но они все еще актуальны. Местами система выглядит избыточной. Стоит ли заморачиваться насчет сложности и стабильности дизайна кода? Решать вам. Однозначного ответа нет. Определиться вы можете в любой момент. Желательно - на зачаточном этапе. Если вам стало понятно, что ваш проект может жить более чем полгода и он будет расти - пишите гибкий код. Не обманывайте себя, что это все оверинженерия. Мобильных приложений с 2-3 экранами уже давно нет. Разработка под мобильные устройства уже давно вошла в разряд enterprise. Быть маневренным - золотой навык. Ваш бизнес не забуксует на месте и поток запланнированных задач реже станет оставать от графика.

Подробнее..

Шаблоны GRASP Polymorphism, Pure Fabrication, Indirection, Protected Variations

01.10.2020 18:11:51 | Автор: admin
Привет, Хабр! Меня зовут Владислав Родин. В настоящее время я являюсь руководителем курса Архитектор высоких нагрузок в OTUS, а также преподаю на курсах, посвященных архитектуре ПО.

Специально к старту нового набора на курс Архитектура и шаблоны проектирования я продолжаю серию своих публикаций про шаблоны GRASP.



Введение


Описанные в книге Craig'а Larman'а Applying UML and patterns, 3rd edition, GRASP'овские паттерны являются обобщением GoF'овских паттернов, а также непосредственным следствием принципов ООП. Они дополняют недостающую ступеньку в логической лестнице, которая позволяет получить GoF'овские паттерны из принципов ООП. Шаблоны GRASP являются скорее не паттернами проектирования (как GoF'овские), а фундаментальными принципами распределения ответственности между классами. Они, как показывает практика, не обладают особой популярностью, однако анализ спроектированных классов с использованием полного набора GRASP'овских паттернов является необходимым условием написания хорошего кода.

Полный список шаблонов GRASP состоит из 9 элементов:

  • Information Expert
  • Creator
  • Controller
  • Low Coupling
  • High Cohesion
  • Polymorphism

В прошлый раз мы обсудили паттерн Controller. Сегодня предлагаю рассмотреть оставшиеся паттерны из списка.

Polymorphism


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

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

Наличие в коде конструкции switch является нарушением данного принципа, switch'и подлежат рефакторингу.

Злоупотребление полиморфизмом приводит к переусложнению кода и в общем случае не приветствуется.

Pure Fabrication


Необходимо обеспечивать low coupling и high cohesion. Для этой цели может понадобиться синтезировать искуственную сущность. Паттерн Pure Fabrication говорит о том, что не стоит стесняться это сделать. В качестве примера можно рассматривать фасад к базе данных. Это чисто искуственный объект, не имеющий аналогов в предметной области. В общем случае любой фасад относится к Pure Fabrication (если это конечно не архитектурный фасад в соответствующим приложении).

Indirection


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

Если переводить на русский язык, то паттерн подразумевает следующее: любой объект в коде необходимо вызывать через его интерфейс (тот самый промежуточный объект).

Indirection является самым ключевым шаблоном из перечисленных в этой статье. Во-первых, он очень прост с точки зрения обеспечения. Во-вторых, он придает коду огромную гибкость, не являясь преждевременной оптимизаций ввиду первого пункта. Если все классы вызывают друг друга через интерфейсы, то это приводит к возможности выдрать любой кусок из системы и переиспользовать его в другом месте. Более того, применение Indirection'а позволяет добавить практически любой шаблон банды четырех особо не напрягаясь или осуществить рефакторинг классов.

Protected Variations


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

На самом деле, это не паттерн, а цель, достигаемая соблюдением остальных паттернов.

Вывод


Шаблоны GRASP состоят из 8 паттернов:
1) Information Expert информацию обрабатываем там, где она содержится.
2) Creator создаем объекты там, где они нужны.
3) Controller выносим логику многопоточности в отдельный класс или компонент.
4) Low Coupling 5) High Cohesion проектируем классы с однородной бизнес-логикой и минимальным количеством связей между собой.
6) Polymorphism различные варианты поведения системы при необходимости оформляем в виде полиморфных вызовов.
7) Pure Fabrication не стесняемся создавать классы, не имеющие аналог в предметной области, если это необходимо для соблюдения Low Coupling и High Cohesion.
8) Indirection любой класс вызываем через его интерфейс.
9) Protected Variations применяя все вышесказанное, получаем устойчивый к изменениям код.



Читать ещё:


Подробнее..

Связность кода на примере генератора ASCII графиков, утилита для операций с интервалами и demo на Blazor WebAssembly

18.03.2021 00:13:34 | Автор: admin

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

Например найти пересечение всех интервалов:

          2              5         7         9                  |--------------|         |---------|        0              3    4         6    7              10  |--------------|    |---------|    |--------------|        1              4    5              8                  |--------------|    |--------------|             Result          2    3                   7    8                       |----|                   |----|// Для упрощения восприятия даты заменены на числа

Для решения подобных задач и предназначена анонсируемая утилита.

Работать с периодами в визуальном представлении гораздо проще, поэтому для тестирования (набор тестов тоже есть) и документации я сделал простенькую генерацию ASCII изображений, как показано выше.

Оказалось, что с помощью полученной ASCII генерилки можно рисовать еще и графики y=f(x).

Рис 1. Демонстрация генерации ASCII графиков на Blazor WebAssembly. Можно указывать несколько произвольных функции зависимости y от x.

Анонс утилиты для работы с интервалами IntervalUtility

GitHub, NuGet, интерактивное demo на Blazor WebAssembly.

Ниже пара примеров работы утилиты. Больше примеров на GitHub.

Найти пересечения коллекций интервалов

var arrayOfArrays = new[] {    new[] { new Interval<int>(2,5), new Interval<int>(7, 9) },    new[] { new Interval<int>(0,3), new Interval<int>(4, 6),             new Interval<int>(7, 10) },    new[] { new Interval<int>(1,4), new Interval<int>(5, 8) },};var intervalUtil = new IntervalUtil();var res = intervalUtil.Intersections(arrayOfArrays);// => [2,3], [7,8]          2              5         7         9                 |--------------|         |---------|       0              3    4         6    7              10 |--------------|    |---------|    |--------------|       1              4    5              8                 |--------------|    |--------------|            Result          2    3                   7    8                      |----|                   |----| 

Вычитание коллекции интервалов

var intervalsA = new[] { new Interval<int>(1, 5), new Interval<int>(7, 10) };var intervalsB = new[] { new Interval<int>(0, 2), new Interval<int>(3, 5),                          new Interval<int>(8, 9) };var intervalUtil = new IntervalUtil();var res = intervalUtil.Exclude(intervalsA, intervalsB);// => [2,3], [7,8], [9,10]     1                   5         7              10     |-------------------|         |--------------|0         2    3         5              8    9|---------|    |---------|              |----|Result          2    3                   7    8    9    10          |----|                   |----|    |----|

На этом полезная часть статьи заканчивается. Дальше будет способ генерации бесполезных ASCII графиков.

Генератор ASCII графиков

Интерактивное demo на Blazor WebAssembly.

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

Основная идея: рисовать ASCII блоками фиксированного размера, по аналогии с пикселями

Нужен способ отображения символов (или блоков символов). Пусть изображение строится блоками одинаковой длины. Так удобнее. Можно сказать что один блок - один пиксель. Пиксели тоже половинами рисовать нельзя. Высота блока также фиксирована - одна строка.

Точно также как и у пикселя, у каждого блока есть позиция:

  • номер строки,

  • номер колонки или, другими словами, номер блока в строке.

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

public class DrawerProcessor {    public void Draw(        // запрашивает блок по номеру строки и номеру блока в строке        Func<int, int, DrawerBlock> blockDraw,         // вызывается когда блок можно отрисовать        Action<string, bool> onBlockDraw) {        int row = 0;        int blockIndex = 0;        var done = false;        while (!done) {            var block = blockDraw(row, blockIndex);            switch (block.Command) {                case DrawerCommand.Continue:                    blockIndex = blockIndex + block.Displacement;                    break;                case DrawerCommand.NewLine:                    row = row + 1;                    blockIndex = 0;                    break;                case DrawerCommand.End:                    done = true;                    break;            }            onBlockDraw(block.Value, done);        }    }}public class DrawerBlock {    public string Value { get; set; }    public DrawerCommand Command { get; set; }    // Смещение индекса блока,    // Value может содержать один или несколько блоков    // Если в Value два блока индекс следующего блока надо сместить на 2    public int Displacement { get; set; } = 1;}

DrawerProcessor только следит за текущей позицией блока. DrawerProcessor не принимает никаких решений:

  • он не формирует содержимое блоков,

  • не решает начать новую строку или нет,

  • не определяет продолжать отрисовку или прервать.

Использование DrawerProcessor:

var drawer = new DrawerProcessor();drawer.Draw(    (row, blockIndex) => {        // вся логика отрисовки в одном куске кода        if (row == 3)            return new DrawerBlock {                 Command = DrawerCommand.End             };        if(blockIndex == 3)            return new DrawerBlock {                Value = Environment.NewLine,                Command = DrawerCommand.NewLine            };        return new DrawerBlock {            Value = $"[{row},{blockIndex}]",            Command = DrawerCommand.Continue        };    },    (blockStr, isEnd) => Console.Write(blockStr));Вывод[0,0][0,1][0,2][1,0][1,1][1,2][2,0][2,1][2,2]// Листинг 1. Использование DrawerProcessor.// Не лучший вариант для сложной логики рисования - вся отрисовка// в одном куске кода (в делегате  (row, blockIndex) => { .. }),// который будет разрастаться.

Зачем может потребоваться выводить сразу двойные блоки, и следовательно, зачем нужно свойство DrawerBlock.Displacement? Предположим - длина блока 1 символ, нужно отобразить интервалы и подписи к концам:

8         11|---------|

11 - это два символа. Отрисовывать 11 двумя разными блоками сложно - нужно запомнить, что пишем 11, 1 уже написали - значит сейчас выводим еще 1 . А если надо написать трехзначное число? Проще сразу нарисовать 11 и пропустить отрисовку следующего блока: установить DrawerBlock.Displacement = 2.

Выражаясь наукообразно: если рисовать 11 двумя разными блоками, появляется необходимость хранить состояние (знать, что рисовал предыдущий блок и возможно пред-предыдущий). Увеличивается связь между блоками (предыдущий блок нарисовал 1 - значит сейчас надо нарисовать еще 1), т.е. увеличивается "связность кода". Связность приводит к усложнению.

Немного удобства:

static class Block {    public static DrawerBlock Continue(string val, int displacement = 1)                 => new() {            Command = DrawerCommand.Continue,            Value = val,            Displacement = displacement        };    public static DrawerBlock End() =>        new() { Command = DrawerCommand.End };    public static DrawerBlock NewLine() =>        new() {             Command = DrawerCommand.NewLine,             Value = Environment.NewLine         };}

Теперь такой код:

return new DrawerBlock {    Value = Environment.NewLine,    Command = DrawerCommand.NewLine};

можно писать короче:

return Block.NewLine();

Рисуем несвязанными между собой блоками

В листинге 1 вся логика отрисовки находится в одном куске кода (в делегате (row, blockIndex) => { .. }). Пока этой логики не много, это наглядно и удобно. Но при разрастании, появлении новых требований/условий - код делегата (row, blockIndex) => { .. } будет расти и усложняться.

Пример: в листинге 1 рисовали кирпичики с цифрами:

[0,0][0,1][0,2][1,0][1,1][1,2][2,0][2,1][2,2]

теперь нужно писать цифры только на кирпичиках в шахматном порядке:

[   ][0,1][   ][1,0][   ][1,2][   ][2,1][   ]

Придется править и усложнять код делегата (row, blockIndex) => { .. }.

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

Т.е. такой подход, как и необходимость хранить состояние (см. предыдущий раздел), увеличивает связность кода. Связность приводит к усложнению.

Поправить ситуацию можно следующим образом. Пусть для каждого блока (кирпичик с цифрами, кирпичик без цифр, блок конца строки, завершающий блок) будет отдельная функция (делегат).

// блок "конец"DrawerBlock end(int row, int blockIndex) =>    row == 3 ? Block.End() : null;// блок "новая строка"DrawerBlock newLine(int row, int blockIndex) =>    blockIndex == 3 ? Block.NewLine() : null;// блок "кирпичик с цифрами"DrawerBlock brick(int row, int blockIndex) =>     Block.Continue($"[{row},{blockIndex}]");

При рисовании нужно просто перебирать функции блоков:

public class BlockDrawer {    readonly DrawerProcessor _DrawerProcessor;    public BlockDrawer(DrawerProcessor drawerProcessor) {        _DrawerProcessor = drawerProcessor             ?? throw new ArgumentNullException(nameof(drawerProcessor));    }    public void Draw(        IReadOnlyCollection<Func<int, int, DrawerBlock>> blockDrawers,         Action<string, bool> onBlockDraw) {        _DrawerProcessor.Draw(                (row, blockIndex) => {                    foreach (var bd in blockDrawers) {                        var block = bd(row, blockIndex);                        if (block != null)                            return block;                    }                    return                         new DrawerBlock { Command = DrawerCommand.End };                },                onBlockDraw            );    }}

Пример использования:

// обратите внимание: порядок блоков важенvar blockDrawers = new Func<int, int, DrawerBlock>[] {     end,    newLine,    brick};var drawer = new DrawerProcessor();var blockDrawer = new BlockDrawer(drawer);blockDrawer.Draw(    blockDrawers,    (blockStr, isEnd) => Console.Write(blockStr));

Сейчас программа рисует тоже самое, что программа в листинге 1 - только кирпичики с цифрами.

Добавим кирпичики без цифр:

static void Main(string[] args) {    DrawerBlock end(int row, int blockIndex) => ...;    DrawerBlock newLine(int row, int blockIndex) => ...;    DrawerBlock brick(int row, int blockIndex) => ...;    // кирпичик без цифр    DrawerBlock brickEmpty(int row, int blockIndex) =>        ((row + blockIndex) % 2 == 0) ? Block.Continue($"[   ]") : null;    var blockDrawers = new Func<int, int, DrawerBlock>[] {         end,        newLine,        brickEmpty, // важно вставить перед brick        brick    };    var drawer = new DrawerProcessor();    var blockDrawer = new BlockDrawer(drawer);    blockDrawer.Draw(        blockDrawers,        (blockStr, isEnd) => Console.Write(blockStr));}Результат[   ][0,1][   ][1,0][   ][1,2][   ][2,1][   ]  // Листинг 2. Использование DrawerProcessor.// За отрисовку каждого блока отвечает отдельная функция.// Функции захардкожены в main - не лучший варинт,// если блоков будет много или они будут более сложные.

Выражаясь наукообразно: использован паттерн цепочка обязанностей. Функции рисования блоков вызываются по цепочке. Функции не связаны между собой. Можно добавлять новые блоки, не изменяя написанный код. Однако отметим, что слабая связь между блоками все же есть - важно вызывать их в нужной последовательности.

В листинге 2 функции захардкожены в методе Main. Нет возможности добавить новую функцию рисования без изменения метода Main. Исправим. Пусть для каждой функции рисования блока будет отдельный класс.

public interface IContextDrawerBlock<TDrawerContext> {    int Priority { get; }    DrawerBlock Draw(int row, int blockIndex, TDrawerContext context);}

В интерфейс блоков рисования добавлен context, чтобы была возможность задавать разные параметры отрисовки. Например, ниже класс блока конца отрисовки узнает о максимальном кол-ве строк через context.RowCount:

class EndDrawer : IContextDrawerBlock<SampleDrawContext> {    public int Priority => 10;    public DrawerBlock Draw(int row, int blockIndex,        SampleDrawContext context)               => row == context.RowCount ? Block.End() : null;}

Как и с функциями, классы блоков надо последовательно перебрать:

public class ContextBlockDrawer<TDrawerContext> {    readonly IReadOnlyCollection<IContextDrawerBlock<TDrawerContext>> _BlockDrawers;    readonly BlockDrawer _Drawer;    public ContextBlockDrawer(        BlockDrawer drawer,         IReadOnlyCollection<IContextDrawerBlock<TDrawerContext>> blockDrawers) {        _Drawer = drawer ?? throw ...        _BlockDrawers = blockDrawers?.Any() == true            ? blockDrawers.OrderBy(bd => bd.Priority).ToArray()            : throw ...    }    public void Draw(TDrawerContext drawerContext,        Action<string, bool> onBlockDraw) {        var drawers = _BlockDrawers.Select(bd => {            DrawerBlock draw(int row, int blockIndex) =>                 bd.Draw(row, blockIndex, drawerContext);            return (Func<int, int, DrawerBlock>)draw;        })        .ToArray();        _Drawer.Draw(drawers, onBlockDraw);    }}

Теперь пример с кирпичиками будет выглядеть так:

// Создание ContextBlockDrawervar drawer = new DrawerProcessor();var blockDrawer = new BlockDrawer(drawer);var blockDrawers = new IContextDrawerBlock<SampleDrawContext>[] {    new EndDrawer(),    new EndLineDrawer(),    new BrickEmptyDrawer(),    new BrickDrawer(),};var ctxBlockDrawer = new ContextBlockDrawer<SampleDrawContext>(    blockDrawer,     blockDrawers);// использование ContextBlockDrawerctxBlockDrawer.Draw(    new SampleDrawContext {        RowCount = 3,        BlockCount = 3    },    (blockStr, isEnd) => Console.Write(blockStr));// Листинг 3. Использование ContextBlockDrawer.// Каждая функция рисования блока в отдельном классе.// Введен контекст рисования.// Классы блоков выглядят не связанными между собой,// однако это не совсем так - мешает необходимость задавать Priority.

Обратите внимание: каждый класс блока имеет свойство Priority, поэтому при создании нового блока придется посмотреть какие Priority у других блоков. По крайней мере приоритет нового блока не должен быть выше блока конца. Т.е. на самом деле между блоками есть связь (хотя и не очень сильная).

Наращиваем функционал переиспользуя старый код, стараемся при переиспользовании минимизировать связи

Посмотрите на код создания ContextBlockDrawer в листинге 3. ContextBlockDrawer использует (переиспользует) в своей работе BlockDrawer и получает его экземпляр через конструктор. BlockDrawer, в свою очередь, использует (переиспользует) DrawerProcessor, и также получает его через конструктор.

Выражаясь наукообразно:

ContextBlockDrawer ->зависит от (читай использует)-> BlockDrawer -> зависит от -> DrawerProcessor.

Получение зависимости через конструктор называется агрегацией.

В листинге 3 при создании ContextBlockDrawer фактически реализовано внедрение зависимостей. При этом самый явный (читай лучший) вариант: внедрение зависимостей через конструктор:

- открывая код класса, сразу видно его зависимости (все зависимости в одном месте - в конструкторе)

- экземпляр класса нельзя получить, не установив его зависимости.

Для сравнения: еще зависимость можно внедрять, например, через поле или получать через сервис локатор:

// внедрение зависимости через поле // (хуже внедрения через конструктор)var ctxBlockDrawer = new ContextBlockDrawer();ctxBlockDrawer.BlockDrawer = blockDrawer;// получение зависимости через сервис локатор// (хуже внедрения через конструктор)public class ContextBlockDrawer<TDrawerContext> {    ...    public void Draw(TDrawerContext drawerContext,         Action<string, bool> onBlockDraw) {                    ...        var blockDrawer = ServiceLocator.Get<BlockDrawer>();        ...    }}// Варианты получения зависимостей которые лучше избегать

Кроме получения зависимости BlockDrawer через конструктор (агрегация), можно прямо в ContextBlockDrawer создать экземпляр BlockDrawer (применить композицию):

public class ContextBlockDrawer<TDrawerContext> {    readonly IReadOnlyCollection<IContextDrawerBlock<TDrawerContext>>  _BlockDrawers;    readonly BlockDrawer _Drawer;    public ContextBlockDrawer(        IReadOnlyCollection<IContextDrawerBlock<TDrawerContext>> blockDrawers) {        // композиция        var drawer = new DrawerProcessor();        _Drawer = new BlockDrawer(drawer);        ...    }

тогда связь между ContextBlockDrawer и BlockDrawer будет более сильной: ContextBlockDrawer не просто будет использовать BlockDrawer, но и должен будет знать как создать BlockDrawer. И знать о зависимостях BlockDrawer(о DrawerProcessor). Т.е. связность кода возрастет, а вместе с ней возрастет и сложность системы.

На этом этапе готов ASCII генератор, которого достаточно для рисования подобных схем - демонстрация работы утилиты интервалов.

Декартова система координат

Рисовать графики зависимости y от x с помощью ContextBlockDrawer можно, но неудобно:

public interface IContextDrawerBlock<TDrawerContext> {    int Priority { get; }    DrawerBlock Draw(int row, int blockIndex, TDrawerContext context);}

Draw принимает row и blockIndex. Рисование идет справа налево и сверху вниз. Для графиков y от х было бы удобней:

public interface IСartesianDrawerBlock<TDrawerContext> {    int Priority { get; }    DrawerBlock Draw(float x, float y, TDrawerContext context);}

Например, этот блок рисует прямую:

class LineDrawer : IСartesianDrawerBlock<СartesianDrawerContext> {    public int Priority => 40;    public DrawerBlock Draw(float x, float y,        СartesianDrawerContext context) {        var y1 = x; // уравнение прямой y=x                // если вычисленное y1 равно текущей позиции y        // (c учетом округления)        if (Math.Abs(y1 -y) <= context.Rounding)            return Block.Continue("#");                    return null;    }}

Нужно сделать возможным использовать новый IСartesianDrawerBlock с уже реализованным ContextBlockDrawer. Нужен адаптер, который стыкует Draw(int row, int blockIndex, TDrawerContext context) и DrawerBlock Draw(float x, float y, TDrawerContext context):

public class СartesianDrawerAdapter<TDrawerContext> :     IContextDrawerBlock<TDrawerContext>    where TDrawerContext : IСartesianDrawerAdapterContext {    readonly IСartesianDrawerBlock<TDrawerContext> _cartesianDrawer;    public СartesianDrawerAdapter(    IСartesianDrawerBlock<TDrawerContext> cartesianDrawer) {        _cartesianDrawer = cartesianDrawer ?? throw ...    }        public int Priority => _cartesianDrawer.Priority;    public DrawerBlock Draw(int row, int blockIndex, TDrawerContext context) {        float x = blockIndex / context.Scale + context.XMin;        float y = context.YMax - row / context.Scale;        return _cartesianDrawer.Draw(x, y, context);    }}public interface IСartesianDrawerAdapterContext {    public float Scale { get; }    public float XMin { get; }    public float YMax { get; }}

Пример использования СartesianDrawerAdapter - график прямой:

// создание ctxBlockDrawer для декартовой системы координатvar drawer = new DrawerProcessor();var blockDrawer = new BlockDrawer(drawer);var blockDrawers = new IСartesianDrawerBlock<СartesianDrawerContext>[] {        new EndDrawer(),        new EndLineDrawer(),        new LineDrawer(),        new EmptyDrawer()    }    .Select(dd =>         // применение адаптера        new СartesianDrawerAdapter<СartesianDrawerContext>(dd))    .ToArray();var ctxBlockDrawer = new ContextBlockDrawer<СartesianDrawerContext>(    blockDrawer,    blockDrawers);// использование ctxBlockDrawerctxBlockDrawer.Draw(new СartesianDrawerContext {    XMin = -2,    XMax = 30,    YMin = -2,    YMax = 8,    Scale = 5,    Rounding = 0.1F},(blockStr, isEnd) => Console.Write(blockStr));

Выражаясь наукообразно: при переходе от IContextDrawerBlock к IСartesianDrawerBlock использован паттерн адаптер - СartesianDrawerAdapter.

Использование можно сделать красивее:

// создание...var ctxBlockDrawer = ...var asciiDrawer =     new AsciiDrawer<СartesianDrawerContext>(ctxBlockDrawer);// использованиеasciiDrawer    // вот тут стало красивее    .OnBlockDraw((blockStr, isEnd) => Console.Write(blockStr))    .Draw(new СartesianDrawerContext {        XMin = -2,        XMax = 30,        ...    });// Листинг 4. Создание и использование AsciiDrawer.

Код AsciiDrawer:

public class AsciiDrawer<TDrawerContext> {    readonly ContextBlockDrawer<TDrawerContext> _ContextBlockDrawer;    readonly Action<string, bool> _onBlockDraw;    public AsciiDrawer(        ContextBlockDrawer<TDrawerContext> contextBlockDrawer,         Action<string, bool> onBlockDraw = null) {        _ContextBlockDrawer = contextBlockDrawer ?? throw ...        _onBlockDraw = onBlockDraw;    }    public AsciiDrawer<TDrawerContext> OnBlockDraw(        Action<string, bool> onBlockDraw) {        // создаем новый экземпляр        // возвращать this (return this) небезопасно при многопоточности        return new AsciiDrawer<TDrawerContext>(            _ContextBlockDrawer,             onBlockDraw);    }    public void Draw(TDrawerContext context) {        if (_onBlockDraw == null)                throw new InvalidOperationException("Use .OnBlockDraw to set up draw output");        _ContextBlockDrawer.Draw(context, _onBlockDraw);    }}

Выражаясь наукообразно: AsciiDrawer сделан неизменным - нет методов, которые бы изменяли его состояние. OnBlockDraw возвращает новый экземпляр (а не this). Неизменность делает класс безопасным для многопоточности.

Все SingleInstance

В листинге 4 все, что под комментарием Создание, можно вынести в регистрацию IoC контейнера. В приложении просто получать и использовать готовый AsciiDrawer.

Объекты ASCII рисовальщика не хранят состояний, еще они неизменяемы - значит можно без опасений использовать одни и те же инстансы в разных местах. В том числе наши объекты можно зарегистрировать в IoC контейнере как SingleInstance.

В итоге в Blazor WebAssembly демонстрации по клику на кнопке Run следующий код:

var res = new StringBuilder();AsciiDrw    .OnBlockDraw((blockStr, isEnd) => {        res.Append(blockStr);        if (isEnd)            // обновление UI            Res = res.ToString();    })    .Draw(new FuncsDrawerContext {        // поля формы        Rounding = Rounding,        Scale = Scale,        XMin = Xmin,        XMax = Xmax,        YMin = Ymin,        YMax = Ymax,        // функции для y от x        Functions = funcs    });

В демонстрации используются следующие блоки:

new EndDrawer(),new EndLineDrawer(),new FuncsDrawer(), // рисует функции указанные в контекстеnew XAxisDrawer(), // рисует ось Xnew YAxisDrawer(), // рисует ось Ynew EmptyDrawer()

Можно еще придумать:

  • блок, который закрашивает площадь под графиком,

  • блок который выводит шкалу на осях,

  • блок, который подписывает точки пересечения графиков.

Заключение

Как видите, даже школьную задачку можно серьезно запутать - главное знать принципы и паттерны.

Подробнее..

Чему можно научиться у фикуса-душителя? Паттерн Strangler

12.06.2021 20:19:26 | Автор: admin

Ссылка на статью в моем блоге

Тропические леса и фикусы-душители

В тропических лесах, где всегда тепло, влажно и много зелени живет одно интересное растение. С необычным названием фикус-душитель. Почему он получил такое имя? Как из фильма ужасов.

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

Крона дерева не может получать достаточно света, потому что фикус забирает его себе. Его листья выше. Корни фикуса высасывают из почвы воду и питательные вещества, так что дереву носителю достается все меньше. В какой-то момент дерево-носитель погибает, но фикусу оно уже не нужно. Его стебли образуют прочную основу, которая повторяет силуэт своей жертвы. Обычно старое дерево полностью сгнивает в таком заключении и от него не остается и следа.Однако внешний образ по прежнему остается его в точности повторяет сам фикус:

Рефакторинг сервиса приложения доставки продуктов

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

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

Допустим имеются следующие действия, которые у нас хранятся в одной таблице:

  • Открыть заказ. В таком случае оформляется сам факт посещения заказа и общая сумма. Пока он открыт в него можно добавлять товары. Затем заказ собирают, отправляют в доставку и в итоге заказ переходит взакрытый статус.

  • Можно оформитьвозврат товара. Если вам не понравился кефир - вы оформляете возврат и вам возвращают его цену.

  • Можносписать бонусысо счета. В таком случае часть стоимости оплачивается этими бонусами.

  • Начисляются бонусы. Каким-либо алгоритмом нам не важно каким конкретно.

  • Также заказ может бытьзарегистрирован в некотором приложении-партнере(ExternalOrder)

Все перечисленная информация по заказам и пользователям хранится в таблице (пусть она будет называтьсяOrderHistory):

id

operation_type

status

datetime

user_id

order_id

loyality_id

money

234

Order

Open

2021-06-02 12:34

33231

24568

null

1024.00

233

Order

Open

2021-06-02 11:22

124008

236231

null

560.00

232

Refund

null

2021-05-30 07:55

3456245

null

null

-2231.20

231

Order

Closed

2021-05-30 14:24

636327

33231

null

4230.10

230

BonusAccrual

null

2021-05-30 09:37

568458

null

33231

500.00

229

Order

Closed

2021-06-01 11:45

568458

242334

null

544.00

228

BonusWriteOff

null

2021-05-30 22:15

6678678

8798237

null

35.00

227

Order

Closed

2021-05-30 16:22

6678678

8798237

null

640.40

226

Order

Closed

2021-06-01 17:41

456781

2323423

null

5640.00

225

ExternalOrder

Closed

2021-06-01 23:13

368358

98788

null

226.00

Логика такой организации данных вполне справедлива на раннем этапе разработки системы. Ведь наверняка пользователь может посмотреть историю своих действий. Где он одним списком видит что он заказывал, как начислялись и списывались бонусы. В таком случае мы просто выводим записи, относящиеся к нему за указанный диапазон. Организовать в виде одной таблицы банальная экономия на создании дополнительных таблиц, их поддержании. Однако, по мере роста бизнес-логики и добавления новых типов операций число столбцов с null значениями начало расти. Записей в таблице сотни миллионов. Причем распределены они очень неравномерно. В основном это операции открытия и закрытия заказов. Но вот операции начисления бонусов составляют 0.1% от общего числа, однако эти записи используются при расчете новых бонусов, что происходит регулярно.В итоге логика расчета бонусов работает медленнее, чем если бы эти записи хранились в отдельной таблице. Ну и расширять таблицу новыми столбцами не хотелось бы в дальнейшем. Кроме того заказы в закрытом статусе с датой создания более 2 месяцев для бизнес-логики интереса не представляют. Они нужны только для отчетов не более.

И вот возникает идея.Разделить таблицу на две, три или даже больше.

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

Изменение структуры хранения в три этапа

Предположим, что наше legacy монолитное приложение хоть и плохое, но не совсем. Как минимум зарезервировано. То есть работает как минимум два экземпляра. Так, что при падении одного из них - второй продолжит обслуживать пользователей:

Между пользователем и монолитом есть прокси, которая в целях упрощения схемы можно не отображать. Просто учтем, что запрос может случайным образом выполнятся на любом экземпляре.

Оба экземпляра работают с одной базой данных. Реализуя паттернShared Database.

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

Отдельная новая база данных вполне может появиться. Однако не всегда. Ввиду сложностей обеспечения транзакционности между двумя БД. Все в конечном счете зависит от реализации и от ограничений бизнес-логики.

Применительно к нашему примеру мы могли бы получить следующую структуру для новых таблиц.

BonusOperations:

id

operation_type

datetime

user_id

order_id

loyality_id

money

230

BonusAccrual

2021-05-30 09:37

568458

null

33231

500.00

228

BonusWriteOff

2021-05-30 22:15

6678678

8798237

null

35.00

Отдельную таблицу для данных из внешних систем -ExternalOrders:

id

status

datetime

user_id

order_id

money

225

Closed

2021-06-01 23:13

368358

98788

226.00

Для операций с заказами моложе, чем 2 недели (предположим, что ограничение бизнес-логики было как раз определено на это уровне. Ведь если заказ был произведен более двух недель назад его нельзя отменить, изменить и прочее) новая таблицаOrderHistoryс уменьшеным числом столбцов.

Для оставшихся типов записей -OrderHistoryArchive(старше 2х недель). Где теперь также можно удалить несколько лишних столбцов.

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

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

Монолит версии 1 и монолит версии 2 вполне могут работать совместно. Лишь для тех запросов, которые обрабатывались монолитом версии 1 в новой базе данных будут пробелы. Эти пробелы, а также недостающие данные можно будет в дальнейшем скопировать отдельным скриптом или утилитой.

Спустя какое-то время работы версии 2 мы получим заполненную новую базу данных. Если все хорошо, то мы готовы к следующей стадии переводу основной бизнес-логики на новую базу данных.

В случае успеха просто удаляем старые таблицы, так как все данные уже сохранены в новой структуре.

Итого. Внешне система никогда не менялась. Однако внутренняя организация радикально преобразилась. Возможно под капотом теперь работает новая система. Которая лишена недостатков предыдущей. Не напоминает фикусов-душителей? Что-то похожее есть. Поэтому именно такое название паттерн и получил Strangler.

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

Выводы

  • ПаттернStranglerпозволяет совершенствовать системы с высокими требованиями к SLA.

  • Для обновления системы без простоя нужно сделать как минимум 3 последовательных развертования на продакшен. Это одна из причин, почему системы требовательные к показателям общего простоя заметно дороже.

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

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

Подробнее..

Паттерны Архитектурного проектирования (v.1.0)(Archicad)

12.01.2021 16:20:24 | Автор: admin

Небольшое вступление

Всем добрый день, работаю в маленькой компании на должности bim-manager и по совместительству ведущим архитектором. Увлекаюсь, изучаю программирование. И чем больше разбираюсь, тем больше завидую.

Архитектура как отрасль очень сильно отстает от IT в плане инстурментов и методов разработки проектов. В IT давно есть среды разработки, а мы по прежнему создаем отдельные файлы и затем долго и нудно собираем их в один проект; ретроспективу после проекта некоторые основатели платных курсов называют своей уникальной методологией которую они придумали; и самое главное нету паттернов. Каждый раз в каждом проекте приходится подолгу объяснять одни и те же решения одних и тех же задач. И вот постепенно такая ситуация привела к решению что пора сформировать/сформулировать эти самые паттерны для архитектурного проектирования. Прежде всего описанные ниже паттерны применимы к разработке в программе Archicad.


Перечень паттернов

По аналогии с программирование показалось не лишним структурировать их в зависимости от типа решаемых задач

Сборочные (или структурные)

Определяют структуру как будет разбит и собран проект или части крупного проекта. Выбор зависит от высоты здания, количества и конфигурации групп типовых элементов

  • Горизонтальная типовость

  • Вертикальная типовость

  • Компоновщик (или древовидная типовость)

  • Сам себе режисер

Преобразования элементов

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

  • Подмена (или заменитель)

  • Проводник

  • Тянущая привязка

  • Лосины (или натягивание на рельеф)

  • Единственный экземпляр

Координационные

Отвечают за способы взаимодействия проектировщиков или файлов с разными ключевыми задачами

  • Наблюдатель

  • Главный файл

  • Фантом

Вариантные

Определяют каким образом внутри проекта будут создаваться варианты

  • MODный вариант

  • Вариант через фильтр реконструкции (не придумал пока как короче назвать)

Подробное описание каждого

Горизонтальная типовость

Ссылка на файлы с примером реализации

Основной принцип - определение этажа как тип целиком

Задача: разбить по типовости большой многоэтажный комплекс. При этом часть элементов может повторятся с 5 по 24 этаж, часть только с 7 по 22, еще часть с 8 по 20.

Решение: распределяем типы буквально как идет повторение по этажам тип1 -> для 3-6, 23-24 этажей, тип2 -> 7,21-22 и тип3 -> 8-20

Плюсы: самая простая для понимания структура сборки. Минимум типовых этажей.

Минусы: будет много повторений элементов (ctrl+c, ctrl+v) и много лишней работы. В чистом виде дальше стадии ПП данный паттерн применять нецелесообразно, т.к. одно малейшее отличие и надо создавать другой тип с вытекающим дублированием огромного количества элементов. И каждый раз добавляя, изменяя элементы на одном типе - нужно будет повторить для идентичных элементов в другом типе

Схема:

Spoiler

Вертикальная типовость

Ссылка на файлы с примером реализации

Основной принцип - определение как тип отдельные группы элементов

Задача: такая же как и у предыдущего - разбить по типовости большой многоэтажный комплекс. При этом часть элементов может повторятся с 5 по 24 этаж, часть только с 7 по 22, еще часть с 8 по 20.

Решение: распределяем, дробим по повторяющимся группам элементов типы как тип1 -> для 3-24 этажей, тип2 -> 7-22 и тип3 -> 8-20, координация типов осуществляется через фоновую ссылку

Плюсы: нету повторяемости элементов. Относительно простая для понимания модель

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

Схема:

Spoiler

Компоновщик

Ссылка на файлы с примером реализации

Основной принцип - выделение как тип только отдельные группы элементов, и выстраивание древовидной структуры. Из мелких групп собираются и дополняются более крупные типовые группы элементов

Задача: такая же как и у предыдущего - разбить по типовости большой многоэтажный комплекс. При этом часть элементов может повторятся с 5 по 24 этаж, часть только с 7 по 22, еще часть с 8 по 20.

Решение: дробим по аналогии с Вертикальной типовостью. Создаем тип1 для частей которые повторяются на 3-24, создаем тип2 для 7-22 и в него подгружаем уже готовый тип1, создаем тип3 для 8-20 и в него подгружаем уже готовый тип2

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

Минусы: очень тяжелая для первого понимания модель, долго обновляются связи в основной модели (в сборке)

Схема:

Spoiler

Сам себе режисер

Ссылка на файлы с примером реализации

Один самодостаточный файл в котором содержатся как типовые группы элементов, так и основная сборка бим модели. Актуален для небольших проектов, стадии ПП

Задача: собрать простую бим модель. Для упрощения архитектор сразу старается закладывать какую-нибудь типовость. Групп типовых элементов не так много что бы создавать и выстраивать структуру из отдельных файлов.

Решение: создаем все в одном файле. У нас есть этажи основной модели (например с -1 по 5). Делаем небольшой отступ ниже ( -2, -3, -4) и начиная с -5го принимаем как этажи на которых будут располагаться типовые группы элементов. Затем эти типовые подгружаем в пространство основной модели. Таким образом получается что файл влагается сам в себя. Сам себе служит .mod-ом

Схема:

Spoiler

Подмена

Ссылка на файлы с примером реализации

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

Задача: Например в элементе модели нужно сделать локальные вставки, или не хочется дробить и нарушать типовость в модах из-за нескольких отличающихся неключевых элементов.

Решение: Ставим колонну, или перекрытие как автоматически вычитающий элемент (можно балку и стену но не рекомендуется из-за возможных сложностей с привязками).

Настроить необходимо так: у элемента которым собираемся вычитать - материал с максимальным приоритетом, вспомогательный слой который будет скрыт (у нас слой для этого есть слой "Элементы выдавливания.ВСП"), уровень слоя выдавливания должен совпадать со слоями на котором лежат целевые элементы в которых собрались делать подмену. Затем моделим то, чем хотим подменить. Эти элементы уже должны использовать слой с другим уровнем, чтобы их так же не вычло по приоритету материалов. В примере подмена реализована как отдельный мод.

Минусы: подходит только для декоративных элементов, инженерии и для локальной пробы вариантов на ранних стадиях проекта.

Схема:

Spoiler

Проводник

Ссылка на файлы с примером реализации

Проводник - файл в котором происходят минимальные изменения или дополнения группы элементов для дальнейшего использования в проекте

Задача: Есть собранная база юнитсов (группа элементов которые отстраиваются и специфицируются как один объект) например перемычки или вентблоки. И нужно их затянуть в разные проекты. Глобально в базе если жёстко замаркировать то в проектах в ведомостях могут получиться разбежности типа ВБ-01 ВБ-04. В каждом проекте используется разный набор вентблоков. Дублировать файл чтобы перебить на свои тоже не имеет смысла тк в случае глобальных корректировок или дополнения, расширения - нужно будет повторить всю работу.

Решение: В исходном файле, основном в котором собраны все типы не индексируем, а подгружаем его как связь в мод юнитсов самого проекта. Маркировку будем обозначать и снимать с ID. Назначаем ID через "ID связи"(Master ID)

Схема:

Spoiler

Тянущая привязка

Ссылка на файлы с примером реализации

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

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

Решение: Стены пляшущего фасада конфигурируем таким образом чтобы привязка всегда располагалась в одном месте. Стены внутренние на каждом этаже будут ловить привязку и автоматически заполнять меняющееся пространство. Зоны так же корректно обновляются

Минусы: Мы больше не можем использовать линию привязки как объективный параметр (напр. "длина линии привязки")

Схема:

Spoiler

Лосины

Ссылка на файлы с примером реализации

Натягивание на ральеф - набор последовательных действий для быстрого эскизного отстроения благоустройства (дорожек, площадок) по рельефу.

Задача: Быстро (не для Р) концептуально но красиво отстроить все дороги, дорожки, площадки на рельефе. Вылепливать вершинами 3д сетки - долго.

Решение: В плане отмоделиваем все элементы ГП которые нужно натянуть на рельеф. Назначем им всем низ - ниже чем самая низкая точка на рельефе (можно 1м, но лучше брать с запасом около 5м), высота - выше чем самая верхняя точка на рельефе. Заходим в "операции твердотельного моделирования", все элементы благоустройства определяем как "целевые элементы", рельеф как "элементы оператора", Если рельеф имеет толщину(тело) то операциях твердотельного моделирования выбираем параметр "пересечение", иначе параметр "вычитание с выталкиванием вверх", жмем "Выполнить", преобразовываем элементы в морф. Далее у нас есть 2 варианта. 1- поднимаем морфы на 20+мм над рельефом. 2- с помощью того же инстурмента вычитаем морфы из земли. Элементы благоустройства получаются как бы натянуты тонким слоем ровно по рельефу.

Примечание: Заметно только с ближних статичных ракурсов. Люмион и твинмоушен может некорректно понимать, что есть верх в такой натянутой дороге и машины/люди могут пойти по самому рельефу, как бы проваливаясь на несколько сантиметров в текстуры.

Spoiler

Единственный экземпляр

Ссылка на файлы с примером реализации

Нацелен на упрощение структуры и облегчение основных сборных файлов, путем отстраивания групп вертикальных элементов в полную высоту с релевантным отображением.

Задача: Отстроить типовые элементы колонн/пилонов, диафрагм жесткости в которых нету проемов в многоэтажном ЖК (18+ этажей). Традиционный способ не очень эффективен с точки зрения использования ресурсов компа - колонна в высоту этажа и поехали по 10+ этажей тиражировать одинаковые модули.

Решение: Создаем и размещаем типовые вертикальные элементы один раз. Настройки следующие: привязка верха - к этажу на 1 выше от того где должен элемент отображаться, отображение на этажах - релевантный. Для отображения на типовых этажах в файлах с отдельными типами - можно размещать элементы на вспомогательных этажах ниже, которые не используются для подгрузки в сборку. (подробнее можно увидеть в файлах примерах сборочных паттернов)

Spoiler

Наблюдатель

Ссылка на файлы с примером реализации

Наблюдатель - файл в котором не происходит сбор/редактирование/создание основной бим модели, но имеет конфигурации и инструменты для аналитики модели или выпуска

Задача: Крупный проект, модель здания необходимо анализировать, сопоставлять с другими моделями от инженеров, конструкторов, производить расчёты, а ещё оформлять и выводить всё это кучей разных способов. Если делать всё в одном файле с моделью то не найдётся в мире настолько мощного компьютера который смог бы такой файл потянуть.

Решение: Отделяем все сопутствующие задачи от модели в отдельные файлы. Модель собираем и редактируем конкретно в одном файле. В остальные подгружаем целиком как связь и проводим там все нужные операции. Например так можно отделить в файл задачу по анализу инсоляции. Грассхоппер будет связываться со специальным для этой задачи файлом, а не моделью над которой работают архитекторы. Можно отделить подсчет объемов материалов, оформление заданий и выпуска и т д. В конечном итоге заведем шаблоны с уникальными для каждой задачи пресетами вместо того чтобы утежелять основной шаблон.

Spoiler

Главный файл

Ссылка на файлы с примером реализации

Главный файл - основной файл в котором собирается бим модель, пригоден для выпуска, и с которого возвращается как подложки PMK

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

Решение: выделяем в структуре проекта главный файл в котором будет собираться конечная модель из модели с планами + модели с фасадами. Для обратной связи выгружаем из главного файла PMK и затягиваем как подложки в модели планов и фасадов. Так же главным файлом может быть объявлен файл модели планов или фасадов

Spoiler

Фантом

Ссылка на файлы с примером реализации

Фантом - способ координации двух моделей, при котором подложенные модели не существуют на тех этажах на которых отображаются.

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

Решение: Отстраиваем модель фасада с параметрами отображение элементов "все релевантные этажи". Остраиваем модель планов этажей с отображением элементов "все релевантные этажи". В одном файле (напр с фасадами) ниже этажей основной модели +несколько этажей для отступа набиваем структуру этажей идентичную основной модели, подгружаем на доп этажи все планы и вручную в 3д стягиваем на отметки в область основной модели. В другом файле проворачиваем тоже самое с фасадами, но на этажах выше основной модели + неск этажей для отступа.

Плюсы: будут подгружены все элементы модели включая вложенные модули. Будет осуществлятся взаимодействие с самой моделью а не фоновой ссылкой или 2д чертежами. Каждый владеет полной бим моделью.

Минусы: Не подходит для невнимательных, т.к. можно запустить тяжелейшую рекурсию. Не подходит для слабых компьютеров

Spoiler

MODный вариант

Ссылка на файлы с примером реализации

MODный вариант - способ создания вариантов, путем вычленения изменяемой геометрии в отдельные .mod файлы, а затем подгрузки как связь.

Задача: В процессе разработки стадии П или ПП вам подкидывают идею/задание "а давай это место попробуем сделать вот так". Получается надо локально и быстро сделать другой вариант, при этом не запороть собранную модель и не перелопачивать всю структуру проекта.

Решение: Те части, которые необходимо заменить - выделяем, вырезаем и сохраняем как мод из буфера обмена, это будет исходный. Дублируем мод, называем как вариант и в нем уже его разрабатываем. В основную модель подгружаем/переключаем моды с вариантами

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

Минусы: На переключение между вариантами уходит время

Spoiler

Вариант через фильтр реконструкции

Ссылка на файлы с примером реализации

Вариант в фильтре реконструкции - способ создания вариантов, путем прикрепления геометрии к конкретному фильтру реконструкции и отображению только в нем.

Задача: Такая же как в предыдущем. В процессе разработки стадии П или ПП вам подкидывают идею/задание "а давай это место попробуем сделать вот так". Получается надо локально и быстро сделать другой вариант при этом не запороть собранную модель и не перелопачивать всю структуру проекта.

Решение: создаем фильтр реконстуркции "исходный вариант" и "вариант " скидываем все элементы, которые надо заменить на фильтр реконструкции "исходный вариант" или базовый, в котором разрабатывается основная модель. Для этого дела нам может пригодиться панель "Фильтры реконструкции". Варианты делаем с привязкой к фильтру конструкции "вариант ."

Плюсы: быстрое переключение между вариантами; всё в одной модели

Минусы: утяжеление модели; невозможно комбинировать варианты разных частей проекта; сложно чистить, если в Teamwork и вариантов много; сложно архивировать варианты т.к. надо сгружать модель полностью. Сложность также состоит в том, что если файл с вариантами нужно куда-то подгрузить как связь - то все фильтры реконструкции необходимо импортировать тоже

Spoiler

Ссылка на папку со всеми файлами примерами на всякий случай

Подробнее..

Паттерн CQRS теория и практика в рамках ASP.Net Core 5

24.02.2021 00:19:22 | Автор: admin

Скорость разработки и производительность программистов могут отличаться в зависимости от их уровня и используемых в проектах технологий. Для проектирования ПО нет станд

артов и ГОСТов, только вы выбираете, как будете разрабатывать свою программу. Один из лучших способов повысить эффективность работы применить шаблон проектирования CQRS.

Существует три вида паттерна CQRS: Regular, Progressive и Deluxe. В этой статье я расскажу о первом классическом паттерне Regular CQRS, который мы используем в DD Planet в рамках разработки онлайн-сервиса Выберу.ру. Progressive и Deluxe более сложные архитектуры и влекут за собой использование обширного набора абстракций.

Я поделюсь опытом своей команды: как мы применили паттерн CQRS в бизнес-приложениях и беспроблемно внедрили его в существующие проекты, не переписывая тысячи строк кода.

Классический Onion

Чтобы было понятно, для чего нужен паттерн CQRS, сначала рассмотрим, как выглядит классическая архитектура приложения.

Классическая луковая архитектура состоит из нескольких слоев:

  1. Доменный слой наши сущности и классы.

  2. Слой бизнес-логики, где происходит вся обработка доменной логики.

  3. Слой приложения логика самого приложения.

  4. Внешние слои: слой UI, базы данных или тестов.

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

Так произошло с нашим сайтом Выберу.ру. Мы получили спагетти-код, в котором связанность была на очень высоком уровне. Новые разработчики приходили в шок, когда его видели. Самое страшное, что могло случиться введение нового сотрудника в приложение. Объяснить, что и почему, казалось просто невозможным.

И мы подошли к моменту, когда перед нами встала важная задача устранить этот недостаток инкапсулировать доменную логику в одном месте, уменьшить связанность и улучшить связность. Мы начали искать новые паттерны проектирования и остановились на CQRS.

CQRS

Определение и задачи

CQRS (Command Query Responsibility Segregation) это шаблон проектирования, который разделяет операции на две категории:

  • команды изменяют состояние системы;

  • запросы не изменяют состояние, только получают данные.

Подобное разделение может быть логическим и основываться на разных уровнях. Кроме того, оно может быть физическим и включать разные звенья (tiers), или уровни.

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

  • повысить скорость разработки нового функционала без ущерба для существующего;

  • снизить время подключения нового работника к проекту;

  • уменьшить количество багов;

  • упростить написание тестов;

  • повысить качество планирования разработки.

Благодаря CQRS мы получаем архитектуру, в которой все аккуратно разложено и понятно (меньше связанность, больше связности), человек может открыть код команды или запроса, увидеть все его зависимости, понять, что он делает, и продолжать работать над ним в рамках только этой команды/запроса, без копания в других частях программы.

Практика

Хочу поделиться, как мы используем шаблон CQRS на практике, и наглядно показать его плюсы.

Мы используем ASP.NET Core 5.0, поэтому примеры реализации паттерна будут в контексте этого фреймворка.

Помимо встроенных механизмов ASP.NET Core 5.0, нам понадобятся еще две библиотеки:

  • MediatR небольшая библиотека, помогающая реализовать паттерн Mediator, который нам позволит производить обмен сообщениями между контроллером и запросами/командами без зависимостей.

  • FluentValidation небольшая библиотека валидации для .NET, которая использует Fluent-интерфейс и лямбда-выражения для построения правил валидации.

Реализация REST API с помощью CQRS

Наши команды и запросы очень хорошо ложатся на REST API:

  • get это всегда запросы;

  • post, put, delete команды.

Добавление и настройка MediatR:

Чтобы добавить библиотеку в наш проект, выполним в консоли команду:

dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

Далее зарегистрируем все компоненты нашей библиотеки в методе ConfigureServices класса Startup:

namespace CQRS.Sample{   public class Startup   {       ...       public void ConfigureServices(IServiceCollection services)       {           ...           services.AddMediatR(Assembly.GetExecutingAssembly());           services.AddControllers();           ...       }   }}

После мы напишем первую команду, пусть это будет команда добавления нового продукта в нашу базу данных. Сначала реализуем интерфейс команды, отнаследовавшись от встроенного в MediatR интерфейса IRequest<TResponse>, в нем мы опишем параметры команды и что она будет возвращать.

namespace CQRS.Sample.Features{   public class AddProductCommand : IRequest<Product>   {       /// <summary>       ///     Алиас продукта       /// </summary>       public string Alias { get; set; }        /// <summary>       ///     Название продукта       /// </summary>       public string Name { get; set; }        /// <summary>       ///     Тип продукта       /// </summary>       public ProductType Type { get; set; }   }}

Далее нам нужно реализовать обработчик нашей команды с помощью IRequestHandler<TCommand, TResponse>.

В конструкторе обработчика мы объявляем все зависимости, которые нужны нашей команде, и пишем бизнес-логику, в этом случае сохранение сущности в БД.

namespace CQRS.Sample.Features{   public class AddProductCommand : IRequest<Product>   {       /// <summary>       ///     Алиас продукта       /// </summary>       public string Alias { get; set; }        /// <summary>       ///     Название продукта       /// </summary>       public string Name { get; set; }        /// <summary>       ///     Тип продукта       /// </summary>       public ProductType Type { get; set; }        public class AddProductCommandHandler : IRequestHandler<AddProductCommand, Product>       {           private readonly IProductsRepository _productsRepository;            public AddProductCommandHandler(IProductsRepository productsRepository)           {               _productsRepository = productsRepository ?? throw new ArgumentNullException(nameof(productsRepository));           }            public async Task<Product> Handle(AddProductCommand command, CancellationToken cancellationToken)           {               Product product = new Product();               product.Alias = command.Alias;               product.Name = command.Name;               product.Type = command.Type;                await _productsRepository.Add(product);               return product;           }       }   }}

Чтобы вызвать исполнение нашей команды, мы реализуем Action в нужном контроллере, пробросив интерфейс IMediator как зависимость. В качестве параметров экшена мы передаем нашу команду, чтобы механизм привязки ASP.Net Core смог привязать тело запроса к нашей команде. Теперь достаточно отправить команду через MediatR и вызвать обработчик нашей команды.

namespace CQRS.Sample.Controllers{   [Route("api/v{version:apiVersion}/[controller]")]   [ApiController]   public class ProductsController : ControllerBase   {       private readonly ILogger<ProductsController> _logger;       private readonly IMediator _mediator;        public ProductsController(IMediator mediator)       {           _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));       }              ...        /// <summary>       ///     Создание продукта       /// </summary>       /// <param name="client"></param>       /// <param name="apiVersion"></param>       /// <param name="token"></param>       /// <returns></returns>       [HttpPost]       [ProducesResponseType(typeof(Product), StatusCodes.Status201Created)]       [ProducesDefaultResponseType]       public async Task<IActionResult> Post([FromBody] AddProductCommand client, ApiVersion apiVersion,           CancellationToken token)       {           Product entity = await _mediator.Send(client, token);           return CreatedAtAction(nameof(Get), new {id = entity.Id, version = apiVersion.ToString()}, entity);       }   }}

Благодаря возможностям MediatR мы можем делать самые разные декораторы команд/запросов, которые будут выполняться по принципу конвейера, по сути, тот же принцип реализуют Middlewares в ASP.Net Core при обработке запроса. Например, мы можем сделать более сложную валидацию для команд или добавить логирование выполнения команд.

Нам удалось упростить написание валидации команд с помощью FluentValidation.

Добавим FluentValidation в наш проект:

dotnet add package FluentValidation.AspNetCore

Создадим Pipeline для валидации:

namespace CQRS.Sample.Behaviours{   public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>       where TRequest : IRequest<TResponse>   {       private readonly ILogger<ValidationBehaviour<TRequest, TResponse>> _logger;       private readonly IEnumerable<IValidator<TRequest>> _validators;        public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators,           ILogger<ValidationBehaviour<TRequest, TResponse>> logger)       {           _validators = validators;           _logger = logger;       }        public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken,           RequestHandlerDelegate<TResponse> next)       {           if (_validators.Any())           {               string typeName = request.GetGenericTypeName();                _logger.LogInformation("----- Validating command {CommandType}", typeName);                 ValidationContext<TRequest> context = new ValidationContext<TRequest>(request);               ValidationResult[] validationResults =                   await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));               List<ValidationFailure> failures = validationResults.SelectMany(result => result.Errors)                   .Where(error => error != null).ToList();               if (failures.Any())               {                   _logger.LogWarning(                       "Validation errors - {CommandType} - Command: {@Command} - Errors: {@ValidationErrors}",                       typeName, request, failures);                    throw new CQRSSampleDomainException(                       $"Command Validation Errors for type {typeof(TRequest).Name}",                       new ValidationException("Validation exception", failures));               }           }            return await next();       }   }}

И зарегистрируем его с помощью DI, добавим инициализацию всех валидаторов для FluentValidation.

namespace CQRS.Sample{   public class Startup   {       ...       public void ConfigureServices(IServiceCollection services)       {           ...           services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));           services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());           ...       }   }}

Теперь напишем наш валидатор.

public class AddProductCommandValidator : AbstractValidator<AddProductCommand>{   public AddProductCommandValidator()   {       RuleFor(c => c.Name).NotEmpty();       RuleFor(c => c.Alias).NotEmpty();   }}

Благодаря возможностям C#, FluentValidation и MediatR нам удалось инкапсулировать логику нашей команды/запроса в рамках одного класса.

namespace CQRS.Sample.Features{   public class AddProductCommand : IRequest<Product>   {       /// <summary>       ///     Алиас продукта       /// </summary>       public string Alias { get; set; }        /// <summary>       ///     Название продукта       /// </summary>       public string Name { get; set; }        /// <summary>       ///     Тип продукта       /// </summary>       public ProductType Type { get; set; }        public class AddProductCommandHandler : IRequestHandler<AddProductCommand, Product>       {           private readonly IProductsRepository _productsRepository;            public AddProductCommandHandler(IProductsRepository productsRepository)           {               _productsRepository = productsRepository ?? throw new ArgumentNullException(nameof(productsRepository));           }            public async Task<Product> Handle(AddProductCommand command, CancellationToken cancellationToken)           {               Product product = new Product();               product.Alias = command.Alias;               product.Name = command.Name;               product.Type = command.Type;                await _productsRepository.Add(product);               return product;           }       }        public class AddProductCommandValidator : AbstractValidator<AddProductCommand>       {           public AddProductCommandValidator()           {               RuleFor(c => c.Name).NotEmpty();               RuleFor(c => c.Alias).NotEmpty();           }       }   }}

Это сильно упростило работу с API и решило все основные задачи.

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

Текущие результаты можно посмотреть на GitHub.

Подробнее..

Пожалуй, лучшая архитектура для UI тестов

16.10.2020 18:23:47 | Автор: admin

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


Привет, Хабр! Меня зовут Диана, я руководитель группы тестирования пользовательских интерфейсов, автоматизирую веб и десктоп тесты уже пять лет. Примеры кода будут на java и для web, но, на практике проверено, подходы применимы и к питону с десктопом.

В начале было...


Вначале было слово, и слов было много, и заполняли они все страницы равномерно кодом, не обращая внимания на эти ваши архитектуры и принципы DRY (dont repeat yourself не надо повторять код, который вы уже написали три абзаца выше).

Простыня


На самом деле, архитектура портянки, она же простыня, она же сваленный в кучу неструктурированный код, равномерно заполняющий экран, не так уж плоха и вполне применима в следующих ситуациях:

  • Быстроклик в три строчки (ну ладно, в двести три) для очень маленьких проектов;
  • Для примеров кода в мини-демо;
  • Для первого кода в стиле хелоу ворд среди автотестов.

Что нужно сделать для получения архитектуры Простыни? просто записать весь нужный код в один файл, общим полотном.

import com.codeborne.selenide.Condition;import com.codeborne.selenide.WebDriverRunner;import org.testng.annotations.Test;import static com.codeborne.selenide.Selenide.*;public class RandomSheetTests {    @Test    void addUser() {        open("https://ui-app-for-autotest.herokuapp.com/");        $("#loginEmail").sendKeys("test@protei.ru");        $("#loginPassword").sendKeys("test");        $("#authButton").click();        $("#menuMain").shouldBe(Condition.appear);        $("#menuUsersOpener").hover();        $("#menuUserAdd").click();        $("#dataEmail").sendKeys("mail@mail.ru");        $("#dataPassword").sendKeys("testPassword");        $("#dataName").sendKeys("testUser");        $("#dataGender").selectOptionContainingText("Женский");        $("#dataSelect12").click();        $("#dataSelect21").click();        $("#dataSelect22").click();        $("#dataSend").click();        $(".uk-modal-body").shouldHave(Condition.text("Данные добавлены."));        WebDriverRunner.closeWebDriver();    }}


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

PageObject


Слышали слухи, что PageObject устарел? Вы просто не умеете его готовить!

Основной рабочей единицей в этом паттерне является страница, то есть законченный набор элементов и действий с ними, например, MenuPage класс, описывающий все действия с меню, то есть клики по вкладкам, раскрытие выпадающих подпунктов и так далее.



Чуть сложнее составить PageObject для модального окошка (для краткости модалки) создания объекта. Набор полей класса понятен: все поля ввода, чекбоксы, выпадающие списки; а для методов есть два варианта действий: можно сделать как универсальные методы заполни все поля модалки, заполни все поля модалки рандомными значениями, проверь все поля модалки, так и отдельные методы заполни название, проверь название, заполни описание и так далее. Что использовать в конкретном случае определяется приоритетами подход один метод на всю модалку увеличивает скорость написания теста, но по сравнению с подходом по одному методу на каждое поле сильно проигрывает в читаемости теста.

Пример
Составим общий Page Object создания пользователей для обоих видов тестов:
public class UsersPage {    @FindBy(how = How.ID, using = "dataEmail")    private SelenideElement email;    @FindBy(how = How.ID, using = "dataPassword")    private SelenideElement password;    @FindBy(how = How.ID, using = "dataName")    private SelenideElement name;    @FindBy(how = How.ID, using = "dataGender")    private SelenideElement gender;    @FindBy(how = How.ID, using = "dataSelect11")    private SelenideElement var11;    @FindBy(how = How.ID, using = "dataSelect12")    private SelenideElement var12;    @FindBy(how = How.ID, using = "dataSelect21")    private SelenideElement var21;    @FindBy(how = How.ID, using = "dataSelect22")    private SelenideElement var22;    @FindBy(how = How.ID, using = "dataSelect23")    private SelenideElement var23;    @FindBy(how = How.ID, using = "dataSend")    private SelenideElement save;    @Step("Complex add user")    public UsersPage complexAddUser(String userMail, String userPassword, String userName, String userGender,                                     boolean v11, boolean v12, boolean v21, boolean v22, boolean v23) {        email.sendKeys(userMail);        password.sendKeys(userPassword);        name.sendKeys(userName);        gender.selectOption(userGender);        set(var11, v11);        set(var12, v12);        set(var21, v21);        set(var22, v22);        set(var23, v23);        save.click();        return this;    }    @Step("Fill user Email")    public UsersPage sendKeysEmail(String text) {...}    @Step("Fill user Password")    public UsersPage sendKeysPassword(String text) {...}    @Step("Fill user Name")    public UsersPage sendKeysName(String text) {...}    @Step("Select user Gender")    public UsersPage selectGender(String text) {...}    @Step("Select user variant 1.1")    public UsersPage selectVar11(boolean flag) {...}    @Step("Select user variant 1.2")    public UsersPage selectVar12(boolean flag) {...}    @Step("Select user variant 2.1")    public UsersPage selectVar21(boolean flag) {...}    @Step("Select user variant 2.2")    public UsersPage selectVar22(boolean flag) {...}    @Step("Select user variant 2.3")    public UsersPage selectVar23(boolean flag) {...}    @Step("Click save")    public UsersPage clickSave() {...}    private void set(SelenideElement checkbox, boolean flag) {        if (flag) {            if (!checkbox.isSelected()) checkbox.click();        } else {            if (checkbox.isSelected()) checkbox.click();        }    }}

А в классе тестов пользователей распишем тест с комплексными действиями:
    @Test    void addUser() {        baseRouter.authPage()                .complexLogin("test@protei.ru", "test")                .complexOpenAddUser()                .complexAddUser("mail@test.ru", "pswrd", "TESTNAME", "Женский", true, false, true, true, true)                .checkAndCloseSuccessfulAlert();    }

И с подробными действиями:
    @Test    void addUserWithoutComplex() {        //Arrange        baseRouter.authPage()                .complexLogin("test@protei.ru", "test");        //Act        baseRouter.mainPage()                .hoverUsersOpener()                .clickAddUserMenu();        baseRouter.usersPage()                .sendKeysEmail("mail@test.ru")                .sendKeysPassword("pswrd")                .sendKeysName("TESTNAME")                .selectGender("Женский")                .selectVar11(true)                .selectVar12(false)                .selectVar21(true)                .selectVar22(true)                .selectVar23(true)                .clickSave();        //Assert        baseRouter.usersPage()                .checkTextSavePopup("Данные добавлены.")                .closeSavePopup();    }

Второй тест очевиден и не требует закапывания в код глубже. Особенно это заметно на действии открытия страницы добавления пользователя: если сотрудник не знает, что для появления определенной ситуации на сайте нужно зайти на первую страницу, открыть третью вкладку, навести курсор на четвертую ссылку, подпрыгнуть и притопнуть сокрытие реализации сильно усложнит ему жизнь. Однако подготовительные действия, например, авторизацию, вполне можно скрыть.


Суть в том, что внутри пейджей инкапсулируются (реализация скрыта, доступны только логические действия) все действия со страницами, таким образом, в тесте используются уже бизнес-функции. А это в свою очередь позволяет писать свои пейджи под каждую платформу (веб, десктоп, мобилки), не меняя при этом тесты.

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

Для уменьшения расхождения между интерфейсами появляется соблазн усложнения отдельных шагов, их выносят в отдельные промежуточные классы, а тесты становятся все менее читаемыми, вплоть до двух шагов: авторизоваться, сделать хорошо, тест закончен. Кроме веба в наших проектах дополнительных интерфейсов не было, а читать кейсы приходится чаще, чем писать, поэтому в угоду читаемости исторические PageObject приобрели новый вид.

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

А что еще бывает?


Как ни странно, не PageObjectом единым!

  • Часто встречается паттерн ScreenPlay, о котором можно почитать например тут. У нас он не прижился, так как использовать bdd-подходы без вовлечения людей, не умеющих читать код бессмысленное насилие над автоматизаторами.
  • У js-фреймворков появляются свои собственные упрощающие жизнь подходы, помимо обязательного PageObject, но при их разнообразии говорить о чем-то устоявшемся и универсальном, мне кажется, слишком смело.
  • Можно написать и что-то свое, например, фреймворк на основе ModelBaseTesting, о чем хорошо рассказали в докладе с гейзенбага докладе с гейзенбага. Этот подход используется в первую очередь в проектах со сложносвязанными объектами, когда обычных тестов не хватает для проверки всех возможных комбинаций состояний и взаимодействий объектов.

А я вам расскажу подробнее про Page Element, позволяющий уменьшить количество однотипного кода, повысив при этом читаемость и обеспечив быстрое понимание тестов даже у тех, кто не знаком с проектом. А еще на нем (со своими блекджеками и преферансами, конечно!) построены популярные не-js фреймворки htmlElements, Atlas и епамовский JDI.

Что такое Page Element?



Для построения паттерна Page Element начнем с самого низкоуровневого элемента. Как говорит Викисловарь, виджет программный примитив графического интерфейса пользователя, имеющий стандартный внешний вид и выполняющий стандартные действия. Например, самый простой виджет Кнопка на него можно кликнуть, у него можно проверить текст и цвет. В Поле ввода можно ввести текст, проверить, какой текст введен, кликнуть, проверить отображение фокуса, проверить количество введенных символов, ввести текст и нажать Enter, проверить placeholder, проверить подсветку обязательности поля и текст ошибки, и всё, что еще может понадобиться в конкретном случае. При этом все действия с этим полем стандартны на любой странице.



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

  • Клик по элементу оглавления с заданным текстом,
  • Проверка существования элемента с заданным текстом,
  • Проверка отступа элемента с заданным текстом.


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

Для уменьшения всеобщего хаоса виджеты, как элементы страницы, объединяются во все те же пейджи, откуда, видимо, и составлено название Page Element.

public class UsersPage {    public Table usersTable = new Table();    public InputLine email = new InputLine(By.id("dataEmail"));    public InputLine password = new InputLine(By.id("dataPassword"));    public InputLine name = new InputLine(By.id("dataName"));    public DropdownList gender = new DropdownList(By.id("dataGender"));    public Checkbox var11 = new Checkbox(By.id("dataSelect11"));    public Checkbox var12 = new Checkbox(By.id("dataSelect12"));    public Checkbox var21 = new Checkbox(By.id("dataSelect21"));    public Checkbox var22 = new Checkbox(By.id("dataSelect22"));    public Checkbox var23 = new Checkbox(By.id("dataSelect23"));    public Button save = new Button(By.id("dataSend"));    public ErrorPopup errorPopup = new ErrorPopup();    public ModalPopup savePopup = new ModalPopup();}


Для использования всего выше созданного в тестах нужно последовательно обращаться к странице, виджету, действию, таким образом получаем следующую конструкцию:

    @Test    public void authAsAdmin() {        baseRouter                .authPage().email.fill("test@protei.ru")                .authPage().password.fill("test")                .authPage().enter.click()                .mainPage().logoutButton.shouldExist();    }


Можно добавить классический слой степов, если есть необходимость для этого в вашем фреймворке (реализация remote библиотеки на Java для RobotFramework требует на вход класс степов, например), или если хочется добавить аннотаций для красивых отчетов. Мы это сделали генератором на основе аннотаций, если интересно, пишите в комментах, расскажем.

Пример класса степов авторизации
public class AuthSteps{    private BaseRouter baseRouter = new BaseRouter();    @Step("Sigh in as {mail}")    public BaseSteps login(String mail, String password) {        baseRouter                .authPage().email.fill(mail)                .authPage().password.fill(password)                .authPage().enter.click()                .mainPage().logoutButton.shouldExist();        return this;    }    @Step("Fill E-mail")    public AuthSteps fillEmail(String email) {        baseRouter.authPage().email.fill(email);        return this;    }    @Step("Fill password")    public AuthSteps fillPassword(String password) {        baseRouter.authPage().password.fill(password);        return this;    }    @Step("Click enter")    public AuthSteps clickEnter() {        baseRouter.authPage().enter.click();        return this;    }    @Step("Enter should exist")    public AuthSteps shouldExistEnter() {        baseRouter.authPage().enter.shouldExist();        return this;    }    @Step("Logout")    public AuthSteps logout() {        baseRouter.mainPage().logoutButton.click()                .authPage().enter.shouldExist();        return this;    }}public class BaseRouter {// Класс для создания страниц, чтобы не дублировать этот код везде, где понадобится обращение к странице    public AuthPage authPage() {return page(AuthPage.class);}    public MainPage mainPage() {return page(MainPage.class);}    public UsersPage usersPage() {return page(UsersPage.class);}    public VariantsPage variantsPage() {return page(VariantsPage.class);}}



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

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

Хранение данных


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

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

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

Для каждой сущности создается описывающая ее модель, которая в самом простом случае содержит названия и типы полей, например вот модель пользователя:

public class User {    private Integer id;    private String mail;    private String name;    private String password;    private Gender gender;    private boolean check11;    private boolean check12;    private boolean check21;    private boolean check22;    private boolean check23;    public enum Gender {        MALE,        FEMALE;        public String getVisibleText() {            switch (this) {                case MALE:                    return "Мужской";                case FEMALE:                    return "Женский";            }            return "";        }    }}


Лайфхак 1: если у вас rest-подобная архитектура клиент-серверного взаимодействия (между клиентом и сервером ходят json или xml объекты, а не кусочки нечитаемого кода), то можно загуглить json to <ваш язык> object, вероятно, нужный генератор уже есть.

Лайфхак 2: если ваши разработчики сервера пишут на том же объектно-ориентированном языке программирования, то можно использовать их модели.

Лайфхак 3: если вы джавист и компания позволяет использовать сторонние библиотеки, а вокруг нет нервных коллег, предсказывающих много боли еретикам, использующим дополнительные библиотеки вместо чистой и прекрасной Java, берите ломбок! Да, обычно IDE может сгенерировать геттеры, сеттеры, toString и билдеры. Но при сравнении наших ломбоковских моделек и разрабских без ломбока виден профит в сотни строк пустого, не несущего бизнес-логики кода на каждый класс. При использовании ломбока не надо бить по рукам тех, кто перемешивает поля и геттеры сеттеры, класс читается легче, можно получить представление об объекте сразу, без пролистывания трех экранов.

Таким образом, у нас появляются каркасы объектов, на которые нужно натянуть тестовые данные. Данные можно хранить как final static переменные, например это может быть полезно для главного админа системы, из под которого создаются другие пользователи. Использовать лучше именно final, чтобы не было соблазна данные изменять в тестах, потому что тогда следующий тест вместо админа может получить бесправного пользователя, не говоря уже про параллельный запуск тестов.

public class Users {    public static final User admin = User.builder().mail("test@protei.ru").password("test").build();}


Для получения данных, не влияющих на другие тесты, можно использовать паттерн прототип и клонировать в каждом тесте свой инстанс. Мы решили сделать проще: написать метод-рандомилку полей класса, примерно вот так:

    public static User getUserRandomData() {        User user = User.builder()                .mail(getRandomEmail())                .password(getShortLatinStr())                .name(getShortLatinStr())                .gender(getRandomFromEnum(User.Gender.class))                .check11(getRandomBool())                .check21(getRandomBool())                .check22(getRandomBool())                .check23(getRandomBool())                .build();//business-logic: 11 xor 12 must be selected        if (!user.isCheck11()) user.setCheck12(true);         if (user.isCheck11()) user.setCheck12(false);        return user;    }


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



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

Этот способ хранения данных использует паттерн Value Object, на его основе можно добавить любых своих хотелок, в зависимости от надобностей проекта. Можно добавить сохранение объектов в базу, и таким образом подготовить систему перед тестом. Можно не рандомить пользователей, а загружать их из файлов properties (и еще одна классная библиотека ). Можно использовать везде одного и того же пользователя, но сделать так называемые Реестры данных (data registry) под каждый вид объектов, в котором к имени или другому уникальному полю объекта будет добавляться значение сквозного счетчика, и в тесте всегда будет свой уникальный testUser_135.

Можно написать свое Хранилище объектов (гуглить object pool и flyweight), из которого запрашивать необходимые сущности в начале теста. Хранилище отдает один из своих уже готовых к работе объектов и отмечает его у себя занятым. В конце теста объект возвращается в хранилище, где его по необходимости чистят, отмечают свободным и отдают следующему тесту. Так делают, если операции создания объектов очень ресурсоемкие, а при таком подходе хранилище работает независимо от тестов и может заниматься подготовкой данных под следующие кейсы.

Создание данных


Для кейсов редактирования пользователя вам определенно понадобится созданный пользователь, которого вы будете редактировать, при этом, в общем-то, тесту редактирования не важно, откуда этот пользователь взялся. Есть несколько способов его создать:

  • нажимать кнопки руками перед тестом,
  • оставить данные от предыдущего теста,
  • развернуть перед тестом из backup,
  • создать кликами по кнопкам прямо в тесте,
  • использовать API.


У всех этих способов есть минусы: если перед тестом надо что-то в системе вводить вручную, то это плохой тест, они потому и называются автотестами, что должны действовать максимально независимо от человеческих рук.

Использование результатов предыдущего теста нарушает принцип атомарности и не дает запускать тест отдельно, придется запускать всю пачку, а ui-тесты не такие уж и быстрые. Хорошим тоном считается писать тесты так, что каждый можно запустить в гордом одиночестве и без дополнительных танцев. Кроме того, бага в создании объекта, уронившая предыдущий тест, вовсе не гарантирует багу в редактировании, а в такой конструкции тест на редактирование упадет следом, и работает ли именно редактирование не узнать.

Использовать backup (сохраненный образ базы данных) с нужными для теста данными уже более менее хороший подход, особенно если backup разворачиваются автоматически или если данные в базу кладут сами тесты. Однако почему в тесте используется конкретно этот объект не очевидно, также могут начатся проблемы пересечения данных при большом количестве тестов. Иногда backup перестают корректно работать из-за обновления архитектуры базы, например, если нужно запустить тесты на старой версии, а в backup уже есть новые поля. С этим можно бороться, организовав хранилище backup под каждую версию приложения. Иногда backup перестают быть валидными опять же из-за обновления архитектуры базы регулярно появляются новые поля, поэтому backup нужно регулярно актуализировать. А еще внезапно может оказаться, что конкретно вот такой единственный пользователь-из-backup никогда не падает, а если бы пользователь был создан только что или имя ему задавали немножко рандомно, то нашли бы баг. Это называется эффект пестицида, тест перестает ловить баги, потому что приложение привыкло к одинаковым данным и не падает, а отклонений в сторону нет.

Если пользователь создан в тесте через клики по тому же самому интерфейсу, то уменьшается пестицид и уходит неочевидность появления пользователя. Минусы аналогичны использованию результатов предыдущего теста: скорость так себе, а еще если будет баг в создании, даже самый маленький (особенно баг теста, например, поменяется локатор кнопки сохранения), то мы не узнаем, работает ли редактирование.

Наконец, еще один способ создания пользователя через http-API из теста, то есть вместо кликов по кнопкам сразу отправить запрос на создание нужного пользователя. Таким образом уменьшен, насколько возможно, пестицид, очевидно, откуда взялся пользователь, а скорость создания сильно выше, чем при кликах по кнопкам. Минусы этого способа в том, что он не подходит для проектов без json или xml в протоколе обмена данными между клиентом и сервером (например если разработчики пишут используя gwt и не хотят писать дополнительный api для тестировщиков). Можно при использовании API потерять кусок логики, выполняемой админкой, и создать не валидную сущность. API может меняться, отчего тесты упадут, однако обычно об этом известно, да и изменения ради изменений никому не нужны, скорее всего это новая логика, которую все равно придется проверять. Также возможно, что и на уровне API будет бага, но от этого ни один способ кроме готовых backup не застрахован, поэтому подходы к созданию данных лучше комбинировать.

Добавим капельку API


Среди способов подготовки данных нам больше всего подошли http-API для текущих нужд отдельного теста и разворачивание backup для дополнительных тестовых данных, которые в тестах не меняются, например, иконок для объектов, чтобы тесты этих объектов не падали при багах в загрузке иконок.

Для создания объектов через API в Java оказалось удобнее всего использовать библиотеку restAssured, хоть она предназначена не совсем для этого. Хочу поделиться парой найденных фишек, знаете еще пишите!

Первая боль авторизация в системе. Её способ нужно для каждого проекта подбирать отдельно, однако есть общее авторизацию нужно выносить в спецификацию запроса, например:

public class ApiSettings {    private static String loginEndpoint="/login";    public static RequestSpecification testApi() {        RequestSpecBuilder tmp = new RequestSpecBuilder()                .setBaseUri(testConfig.getSiteUrl())                .setContentType(ContentType.JSON)                .setAccept(ContentType.JSON)                .addFilter(new BeautifulRest())                .log(LogDetail.ALL);        Map<String, String> cookies = RestAssured.given().spec(tmp.build())                .body(admin)                .post(loginEndpoint).then().statusCode(200).extract().cookies();        return tmp.addCookies(cookies).build();    }}


Можно добавить возможность сохранения Cookies конкретного пользователя, тогда количество запросов к серверу уменьшится. Второе возможное расширение этого метода сохранять полученные Cookies для текущего теста, и подкидывать их драйверу браузера, пропуская шаг авторизации. Выигрыш секунды, но если их умножить на количество тестов, можно неплохо ускориться!

Есть плюшечка для аллюра и красивых отчетов, обратите внимание на строку .addFilter(new BeautifulRest()):

Класс BeautifulRest

public class BeautifulRest extends AllureRestAssured {        public BeautifulRest() {}        public Response filter(FilterableRequestSpecification requestSpec, FilterableResponseSpecification responseSpec, FilterContext filterContext) {            AllureLifecycle lifecycle = Allure.getLifecycle();            lifecycle.startStep(UUID.randomUUID().toString(), (new StepResult()).setStatus(Status.PASSED).setName(String.format("%s: %s", requestSpec.getMethod(), requestSpec.getURI())));            Response response;            try {                response = super.filter(requestSpec, responseSpec, filterContext);            } finally {                lifecycle.stopStep();            }            return response;        }}



Модели объектов отлично ложатся на restAssured, так как библиотека сама справляется с сериализацией и десериализаций моделей в json/xml (превращением из json/xml форматов в объект заданного класса).

    @Step("create user")    public static User createUser(User user) {        String usersEndpoint = "/user";        return RestAssured.given().spec(ApiSettings.testApi())                .when()                .body(user)                .post(usersEndpoint)                .then().log().all()                .statusCode(200)                .body("state",containsString("OK"))                .extract().as(User.class);    }


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

    public static Object create(String endpoint, Object model) {        return RestAssured.given().spec(ApiSettings.testApi())                .when()                .body(model)                .post(endpoint)                .then().log().all()                .statusCode(200)                .body("state",containsString("OK"))                .extract().as(model.getClass());    }    @Step("create user")    public static User createUser(User user) {                  create(User.endpoint, user);    }


Еще раз про рутинные операции


В рамках проверки редактирования объекта нам в целом не важно, как объект появился в системе через api или из backup, или все-таки создан ui-тестом. Важные действия найти объект, нажать у него иконку редактировать, очистить поля и заполнить их новыми значениями, нажать сохранить и проверить, все ли новые значения правильно сохранились. Всю ненужную информацию, не относящуюся непосредственно к тесту, лучше убирать в отдельные методы, например, в класс степов.

    @Test    void checkUserVars() {        //Arrange        User userForTest = getUserRandomData();        // Проверка корректности сохранения полей уже есть в другом тесте,  // этот тест проверяет отображение вариантов из-под залогинившегося юзера,  // поэтому не важно, как юзер создан        usersSteps.createUser(userForTest);        authSteps.login(userForTest);        //Act        mainMenuSteps                .clickVariantsMenu();        //Assert        variantsSteps                .checkAllVariantsArePresent(userForTest.getVars())                .checkVariantsCount(userForTest.getVarsCount());        //Cleanup        usersSteps.deleteUser(userForTest);    }


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

    @Test    void authAsAdmin() {        authSteps.login(Users.admin);// Это всё, просто авторизовались под админом. Все действия и проверки внутри. // Не очень очевидно, не правда ли? 


Если в сьюте появляются практически одинаковые тесты, у которых отличается только подготовка данных, (например, надо проверить, что все три разновидности разноправных пользователей могут совершить одни и те же действия, или есть разные виды управляющих объектов, для каждого из которых надо проверить создание одинаковых зависимых объектов, или нужно проверить фильтрацию по десяти видам статусов объектов), то все равно нельзя повторяющиеся части выносить в отдельный метод. Совсем нельзя, если читаемость вам важна!

Вместо этого нужно почитать про data-driven тесты, для Java+TestNG это будет примерно так:

    @Test(dataProvider = "usersWithDifferentVars")    void checkUserDifferentVars(User userForTest) {        //Arrange        usersSteps.createUser(userForTest);        authSteps.login(userForTest);        //Act        mainMenuSteps                .clickVariantsMenu();        //Assert        variantsSteps                .checkAllVariantsArePresent(userForTest.getVars())                .checkVariantsCount(userForTest.getVarsCount());    } // Метод возвращает пользователей с полным перебором трех булевых параметров.  // Предположим, это важное бизнес-требование.    @DataSupplier(name = "usersWithDifferentVars")    public Stream<User> usersWithDifferentVars(){        return Stream.of(            getUserRandomData().setCheck21(false).setCheck22(false).setCheck23(false),            getUserRandomData().setCheck21(true).setCheck22(false).setCheck23(false),            getUserRandomData().setCheck21(false).setCheck22(true).setCheck23(false),            getUserRandomData().setCheck21(false).setCheck22(false).setCheck23(true),            getUserRandomData().setCheck21(true).setCheck22(true).setCheck23(false),            getUserRandomData().setCheck21(true).setCheck22(false).setCheck23(true),            getUserRandomData().setCheck21(false).setCheck22(true).setCheck23(true),            getUserRandomData().setCheck21(true).setCheck22(true).setCheck23(true)        );    }


Тут используется библиотека Data Supplier, которая является надстройкой над TestNG Data Provider, позволяющей использовать типизированные коллекции вместо Object [] [], но суть та же. Таким образом мы получаем один тест, выполняемый столько раз, сколько входных данных он получает.

Выводы


Итак, для создания большого, но удобного проекта автотестов пользовательских интерфейсов нужно:
  • Описать все мелкие виджеты, встречаемые в приложении,
  • Собрать виджеты в страницы,
  • Создать модели для всех видов сущностей,
  • Добавить методы генерации всех видов сущностей на основе моделей,
  • Подумать о подходящем методе создания дополнительных сущностей
  • Опционально: сгенерировать или собрать вручную файлы степов,
  • Писать тесты так, чтобы в секции основных действий конкретного теста не было сложносоставных действий, только очевидные операции с виджетами.

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

Примеры кода из статьи в виде готового проекта добавлены в гит.
Подробнее..

Перевод Паттерны и Методологии Автоматизации UI Примеры из жизни

01.04.2021 16:06:37 | Автор: admin

Полезные паттерны для автоматизации тестирования UI

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

Большим преимуществом использования паттернов проектирования является то, что они упрощают общение с другими разработчиками и тестировщиками. Если все знают об основных принципах работы паттерна, вам просто нужно упомянуть его название, чтобы быть на одной волне, вместо того чтобы каждый раз подробно демонстрировать свой код.

Паттерн Декоратор (Decorator)

Мой фреймворк должен поддерживать различные варианты компонентов веб-сайта. Это необходимо, потому что наше веб-приложение постоянно меняется, и A/B-тесты выполняются на уровне компонентов.

Если у вас есть подобное требование, паттерн Декоратор может вам подойти! Он позволяет упаковать компоненты в конверты, которые перезаписывают или дополняют только определенные функции. Вам не нужно писать новый класс для каждой новой характеристики компонента: должны быть реализованы только изменения. Вы также можете использовать этот метод, если веб-компоненты меняются в зависимости от размера браузера или типа устройства.

Пример Декоратора

В этом примере у нас есть два компонента Login. У второго есть дополнительная кнопка отменить.

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

Давайте посмотрим на это с точки зрения декоратора!

LoginComponent - это интерфейс для каждого компонента Login. В нем говорится, что каждый компонент должен иметь определенный метод login.

package decorator;public interface LoginComponent {   void login(String user, String password);}

BasicLoginComponent имеет конкретную реализацию метода login. В этом примере он просто выводит Basic login в командную строку.

package decorator;public class BasicLoginComponent implements LoginComponent {   @Override   public void login(String user, String password) {       System.out.println("Basic login: " + user + ", " + password);   }}

Этот класс является сердцем паттерна. LoginDecorator может брать любой LoginComponent и оборачивать его нужными функциями. После этого результатом остается LoginComponent.

package decorator;public abstract class LoginDecorator implements LoginComponent {   private final LoginComponent loginComponent;   public LoginDecorator(LoginComponent loginComponent) {       this.loginComponent = loginComponent;   }   @Override   public void login(String user, String password) {       loginComponent.login(user, password);   }}

MobileLoginDecorator переопределяет функциональность login новым классом, специфичным для мобильных устройств. Опять же, он просто выводит Mobile login, чтобы этот пример был коротким.

package decorator;public class MobileLoginDecorator extends LoginDecorator {   public MobileLoginDecorator(LoginComponent loginComponent) {       super(loginComponent);   }   @Override   public void login(String user, String password) {       System.out.println("Mobile login: " + user + ", " + password);   }}

CancelButtonDecorator может добавить функцию cancel в любой компонент Login.

package decorator;public class CancelButtonDecorator extends LoginDecorator {   public CancelButtonDecorator(LoginComponent loginComponent) {       super(loginComponent);   }   public void cancel() {       System.out.println("Click the cancel button");   }}

Теперь мы можем проверить, как все это работает!

package decorator;public class Main {   public static void main(String[] args) {   System.out.println("DECORATOR PATTERN");   System.out.println("=================");   // This is the basic login component   LoginComponent loginComponent = new BasicLoginComponent();   loginComponent.login("User", "PW");   // Let's turn it into a mobile login component.   loginComponent = new MobileLoginDecorator(loginComponent);   loginComponent.login("User", "PW");   // Finally, we can add a cancel functionality.   loginComponent = new CancelButtonDecorator(loginComponent);   ((CancelButtonDecorator) loginComponent).cancel();   }}

Результат всего этого:

DECORATOR PATTERN=================Basic login: User, PWMobile login: User, PWClick the cancel button

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

Page Object и Page Component

Одним из первых паттернов, характерных для автоматизации UI, является паттерн Page Object. Это означает, что все функции конкретной страницы заключены в класс. Это хорошо для простых представлений без большого количества возможностей взаимодействия, поскольку объекты страницы ясны и управляемы.

Однако, если страница содержит много функций, классы объектов страницы могут стать огромными и превратиться в сложный и хаотичный код. Здесь и появляется расширение объектов страницы: Page Component. Идея состоит в том, чтобы обернуть функциональность компонента в класс, а не всю страницу.

Пример Page Object

Это очень простой интернет-магазин, который включает поиск и список результатов найденных продуктов. Если вы реализуете это с помощью Page Object, результат может выглядеть примерно так, как этот класс WebshopPage.

package pageobjects;public class WebshopPage {   public void search(final String queryString) {       System.out.println("Enter " + queryString);       System.out.println("Click search button");   }   public void checkResultHeadline() {       System.out.println("Check if the headline is correct.");   }   public void checkResults() {       System.out.println("Check if there are search results.");   }}

Все действия, которые можно выполнить на этой странице, включены сюда. Мы можем проверить это с помощью простого Main класса.

package pageobjects;public class Main {   public static void main(String[] args) {   System.out.println("PAGE OBJECTS");   System.out.println("============");   WebshopPage webshopPage = new WebshopPage();   webshopPage.search("T-Shirt");   webshopPage.checkResultHeadline();   webshopPage.checkResults();   }}

Как и ожидалось, это дает нам следующий результат:

PAGE OBJECTS============Enter T-ShirtClick search buttonCheck if the headline is correct.Check if there are search results.

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

Пример Page Component

Здесь на помощь приходит Page Component. В нашем случае вы можете разделить страницу на два компонента: панель поиска и список результатов.

Класс SearchBar должен содержать только метод поиска.

package pagecomponents;public class SearchBar {   public void search(final String queryString) {       System.out.println("Enter " + queryString);       System.out.println("Click search button");   }}

Методы проверки заголовка результата и самих результатов относятся к ResultList:

package pagecomponents;public class ResultList {   public void checkResultHeadline() {       System.out.println("Check if the headline is correct.");   }   public void checkResults() {       System.out.println("Check if there are search results.");   }}

Есть еще WebshopPage, но в этой версии просто доступны два компонента.

package pagecomponents;public class WebshopPage {   public SearchBar searchBar() {       return new SearchBar();   }   public ResultList resultList() {       return new ResultList();   }}

Нам нужно внести лишь небольшие изменения в пример. Вместо доступа к функциям через страницу, теперь мы можем делать это через компоненты.

package pagecomponents;public class Main {   public static void main(String[] args) {   System.out.println("PAGE COMPONENTS");   System.out.println("===============");   WebshopPage webshopPage = new WebshopPage();   webshopPage.searchBar().search("T-Shirt");   webshopPage.resultList().checkResultHeadline();   webshopPage.resultList().checkResults();   }}

Результат все тот же:

PAGE COMPONENTS===============Enter T-ShirtClick search buttonCheck if the headline is correct.Check if there are search results.

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

Паттерн Фабрика (Factory)

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

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

Пример Фабрики

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

Во-первых, нам нужен класс, от которого наследуются все наши компоненты, поэтому все они имеют метод инициализации. Мы также могли бы использовать интерфейс, если бы каждый компонент функции мог иметь другой метод инициализации. Для простоты предположим, что инициализация одинакова для всех.

package factory;public class Component {   public void initialize() {       System.out.println("Initializing " + getClass().getName());   }}

Каждый из перечисленных выше компонентов может быть унаследован от этого класса Component:

public class ResultList extends Component {    ...}public class SearchBar extends Component {    ...}

Недостающая часть - это фабричный класс, который отвечает за производство компонентов. Для иллюстрации фабричный класс принимает имя компонента в качестве аргумента, создает экземпляр соответствующего класса, вызывает его метод initialize и возвращает его обратно.

package factory;public class ComponentFactory {   public static Component getComponent(final String componentName) throws Exception {   System.out.println("Creating " + componentName + "...");   // Create a component instance for the passed in component name.   Component component;   switch (componentName){       case "SearchBar":           component = new SearchBar();           break;       case "ResultList":           component = new ResultList();           break;       default:           throw new Exception(componentName + " unknown.");   }   System.out.println("Component created: " + component);   component.initialize();   return component;   }}

Код Main класса не выглядит иначе, потому что WebshopPage по-прежнему отвечает за управление его компонентами.

package factory;public class Main {   public static void main(String[] args) throws Exception {   System.out.println("FACTORY PATTERN");   System.out.println("===============");   WebshopPage webshopPage = new WebshopPage();   webshopPage.searchBar().search("Berlin");   }}

Результат измененного примера:

FACTORY PATTERN===============Creating SearchBar...Component created: factory.SearchBar@3d075dc0Initializing factory.SearchBarEnter BerlinClick search button

Компонент запрашивается, создается и инициализируется должным образом.

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

Внедрение зависимости (Dependency Injection)

Этот паттерн происходит от идеи инверсии контроля. В этом паттерне объекты получают другие объекты, которые им нужны, извне, вместо того, чтобы создавать их самостоятельно. Это упрощает создание вложенных классов и значительно упрощает их юнит тестирование.

Как правило, программное обеспечение, использующее внедрение зависимостей, полагается на какую-то специализированную среду, такую как Spring или Guice, которая обрабатывает создание и внедрение объектов. Чтобы прояснить концепцию, в следующем примере фреймворк не используется.

Пример Внедрения зависимости

Здесь мы хотим предоставить функции входа в систему для тестирования UI простого веб-сайта. Однако мы хотим хранить имя пользователя и пароль непосредственно внутри класса, поэтому нам не нужно передавать их всякий раз, когда мы хотим вызвать логин. Кроме того, наше требование состоит в том, что у нас может быть несколько наборов имени пользователя и пароля в зависимости от тест-кейса. Это новое требование не позволяет нам просто включить данные для логина на страницу Login, поскольку она должна оставаться гибкой. Вот почему вводить данные извне - хороший выбор.

Это интерфейс LoginData, который должны реализовывать все наши экземпляры данных для входа. Он просто возвращает имя пользователя и пароль.

package dependencyinjection;public interface LoginData {   String getUserName();   String getPassword();}

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

package dependencyinjection;public class LoginDataReal implements LoginData {   @Override   public String getUserName() {       return "Real user";   }   @Override   public String getPassword() {       return "Real password";   }}package dependencyinjection;public class LoginDataFake implements LoginData {   @Override   public String getUserName() {       return "Fake user";   }   @Override   public String getPassword() {       return "Fake password";   }}

Конструктор LoginPage принимает экземпляр класса LoginData и использует его в своем методе login. Таким образом, фактическое имя пользователя и пароль для использования не управляются самой страницей LoginPage, а вместо этого выбираются и вводятся извне.

package dependencyinjection;public class LoginPage {   private final LoginData loginData;   public LoginPage(final LoginData loginData) {       this.loginData = loginData;   }   public void login(){       System.out.println("Logging in with " + loginData.getClass());       System.out.println("- user: " + loginData.getUserName());       System.out.println("- password: " + loginData.getPassword());   }}

Единственная отсутствующая часть - это тестовый класс, который использует оба набора данных для входа.

package dependencyinjection;public class Main {   public static void main(String[] args) {   System.out.println("DEPENDENCY INJECTION");   System.out.println("====================");   LoginPage loginPageReal = new LoginPage(new LoginDataReal());   loginPageReal.login();   LoginPage loginPageFake = new LoginPage(new LoginDataFake());   loginPageFake.login();   }}

Этот класс создает две отдельные страницы входа в систему, которые отличаются только переданными данными входа. Запуск класса выводит следующее:

DEPENDENCY INJECTION====================Logging in with class dependencyinjection.LoginDataRealuser: Real userpassword: Real passwordLogging in with class dependencyinjection.LoginDataFakeuser: Fake userpassword: Fake password

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

Вы можете видеть, что это делает наше ПО более гибким, поскольку его компоненты легко перепрограммировать без изменения какого-либо кода в большинстве классов. Кроме того, это приводит к более тестируемому коду, поскольку каждый класс можно тестировать как отдельную единицу.

И наконец: Две Методологии

Мы только что просмотрели много кода. Итак, давайте завершим эту статью чем-то совершенно другим: методологиями!

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

Не усложняй (Keep It Simple)

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

Вам это не понадобится (You Arent Gonna Need It)

Лично для меня этот принцип, также известный как YAGNI, является одним из самых важных. Он связан с сохранением простоты в том смысле, что вы должны делать самое простое, что может работать. Это означает, что новые функции следует добавлять только тогда, когда вы уверены, что они необходимы, а не потому, что вы думаете, что они понадобятся в какой-то момент.

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

Переведено командой QApedia. Еще больше переведенных статей вы найдете на нашем телеграм-канале.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru