Исходные данные:
Имеется настольное приложение. Для простоты будем считать, что приложение содержит только одну форму.
Функционал приложения: скачать по сети некоторую модель, натянуть эту модель на форму, пользователь эту модель редактирует, по окончании измененная модель едет дальше по сети
Технологии: WPF, MVVM
Особенности приложения:
Отличительная черта приложения состоит в том, что эта форма содержит очень мног
полей, очень много логики, очень много связей между полями. Грубо говоря, 90% кода - это логика и обслуживание полей на форме.
Архитектура:
Как я уже упомянул, используется MVVM паттерн как основа архитектуры. Модель, груб
говоря, здесь выражена как чисто POCO объект - сущность, которая предназначена для сериализации/десериализации при общении с сервером + её можно собрать из классов ViewModel
История:
Изначально всё было просто - одна модель, одна вьюмодель, одна вьюха
Однако, требования всё отгружали и отгружали, когда количество полей перевалило з
50, пришлось делить главную вьюмодель, модель и представление на части, но это всё п
прежнему собиралось в одну форму. Главная вьюмодель осталась, но, чтобы уменьшить сложность, из неё были вынесены все неосновные поля, эти поля были сгруппированы в более мелкие вьюмодели и код был организован так, что мелкие вьюмодели знали о главной и главная знала о мелких.
Форма стала выглядеть как то так:
Код выглядел как то так:
public class MainViewModel
{
public Field1 Field1{get;set;}
public Field2 Field2{get;set;}
public ViewModel1 ViewModel1{get;set;}
public ViewModel2 ViewModel2{get;set;}
public ViewModel3 ViewModel3{get;set;}
}
public class ViewModel1
{
public Field3 Field3{get;set;}
public ViewModel1(MainViewModel main)
{
}
}
public class ViewModel2
{
public Field4 Field4{get;set;}
public ViewModel2(MainViewModel main)
{
}
}
public class ViewModel3
{
public Field5 Field5{get;set;}
public ViewModel3(MainViewModel main)
{
}
}
Но дальше - больше. Если количество полей растет линейно, то количество логически
взаимосвязей между полями - растет гораздо быстрей. Очень быстро появились кросс-секционны
поля, которые нельзя строго отнести к какой то секции, так как они состоят в логическо
зависимости от полей из нескольких секций сразу. Конечно, верным решением было бы вынести вообще взаимодействие полей в модель, но этого не было сделано, а вместо этого логика продолжала существовать во вьюмоделях. Таким образом, потребовался механизм взаимодействия полей между вьюмоделями, который был решен с помощью локальной шины. Всё стало ещё интересней
public class ViewModel1
{
public Field3 Field3
{
get => _field3
set
{
var oldValue = _field3;
if (SetProperty(ref _field3, value))
LocalBus.Raise(new Field3Cahnged(oldValue, value))
}
}
public ViewModel1(MainViewModel main)
{
}
}
public class ViewModel2
{
public Field4 Field4{get;set;}
public ViewModel2(MainViewModel main)
{
LocalBus.Subscribe(ev => {.. logic ..});
}
}
Проблемы
Как несложно догадаться, спустя всего несколько лет эксплуатирования подобной схемы
бизнес-логика стала просто мешаниной событий, валидация стала ночным кошмаром, отладка событий в многопоточном окружении растягивается при любом расследовании логики работы приложения. Основное, что могу выделить
Код больше не отражает бизнес-процессы. Установка значения для поля может обернуться 10 последовательными событиями. Или параллельными.
race condition встречается очень часто
тестируемость решения низкая. Чтобы написать тест логики на изменение поля, над
нагородить вьюмоделей и постоянно проверять что проехало по шине + какие изменения произошл
в моделях. Так как всё взаимосвязано, то зачастую приходится поднимать все вьюмодели для теста одного изменения. Как итог, тесты либо отсутствуют, либо быстро устаревают. Количество дефектов растет.
Расследование дефектов занимает много времени
Внесение изменений занимает много времени
Для упрощения, я не стал расписывать другие аспекты формы, например
Баннеры, алерты, всплывающие сообщения
асинхронная работа (поля активно работают с сетью)
наборы секций и полей на экране сильно зависят от модели. То есть модели с разным
данными, грубо говоря, обращаются в разные комбинации вьюмоделей и по разному представлены на форме
Фоновые бизнес процессы могут в любой момент вмешаться в работу формы
Какие-либо другие сюрпризы, хранящиеся в исходном коде, наподобии самописных AOP, пулах всего подряд и проч.
Вопрос
Я постарался расписать подход работы со сложными формами, который был реализова
в работающем приложении. Я не могу сказать, что он совсем нерабочий - нет, он дает некоторое разделение кода, и до определенного момента работает.
Однако, текущая архитектура нуждается если не в полной переработке, то точно в серьезном рефакторинге в целях:
повышения качества продукта, его тестируемости
уменьшения количества дефектов
уменьшения времени добавления новых фич
Как бы я видел идеальную архитектуру, она должна позволять следующее:
код полностью отражает бизнес процессы. Если в коде есть метод SetModelQuantity(...) - то содержимое метода ясно расскажет, что происходит при этом изменении
Код легко тестировать. Должна быть возможность для юнит тестирования любой ново
функциональности
Вероятно, логика модели должна быть отделена от логики вьюмоделей. При этом встаёт вопрос - а как будут общаться модели разных секций?
Поэтому вопросы звучат так:
какой подход к организации кода/архитектуры вы бы могли посоветовать?
Какие примеры архитектур, решающие схожую задачу существуют?
Какие этапы рефакторинга вы бы могли посоветовать? И как при этом не упасть в качестве
учитывая примерно 50% покрытие тестами (остальные 50% как то работают, но никто не знает как)?
Любые ссылки или литература приветствуются
Ответы
Ответ 1
Боюсь показаться банальным, но принципы SOLID пока еще никто не отменял:
Классы вью моделей не должны зависеть от других вью моделей напрямую, только от абстракций (интерфейсов). Уменьшение прямых зависимостей заметно облегчит юнит тестирование.
Ответственность вью модели - контролировать состояние вьюхи на основе модели и выступат
между ними посредником. Не пытайтесь уместить всю бизнес логику во вью модель, этим должны заниматься сервисы.
Шина (медиатор) во многом является анти-паттерном, так как делает зависимости межд
компонентами неочевидными. Иногда это бывает полезно, но, скорее всего, не в вашем случае
Советую рассмотреть вариант коммуникации через модель/сервисы (когда вью модели подписываются на изменение состояния модели). Шине можно оставить нейтральные нотификации, от которых не зависит поведение конкретной вью модели (например "значения свойства изменилось", но при этом вью модели безразлично кто и как среагирует на эту нотификацию).
Избежать проблем с многопоточностью (в частости race condition) можно с помощью очеред
background операций, с возможностью их отмены в любой момент (т.е. некое подобие Dispatcher-а).
Ответ 2
Неоднократно приходится сталкиваться с подобными проблемами, поэтому выскажу свои мысли по этому вопросу:
Запутывание начинается с момента когда дочерняя VM начинает знать о родительской VM.
Бизнес-логика должна быть в модели. Если это правило не соблюдается, то она размазывается по вью-моделям, что приводит к двусторонней связи между дочерней и родительской VM.
public class MainViewModel
{
public Field1 Field1 { get; set;}
public ViewModel1 ViewModel1 { get; set;}
ModelModel mainModel;
}
public class ViewModel1
{
public Field3 Field3{get;set;}
public ViewModel1(MainViewModel mainVM)
{
}
}
в дочернюю VM необходимо передавать либо часть главной модели, либо всю модель сразу(ка
в вашем случае). Оттого и ViewModel и переводится как "представление модели" потому что она представляет модель, а не вью-модель не правда ли?)
public class ViewModel1
{
public Field3 Field3{get;set;}
public ViewModel1(MainModel model)
{
}
}
"двустороннюю" связь можно реализовать через события модели. Главная/дочерняя вью-модель подписываются на нужные события модели и реагируют на изменения.
public class ViewModel1
{
public Field3 Field3{get;set;}
public ViewModel1(MainModel model)
{
model.SomePropertyChanged += modelSomePropertyChanged;
}
void modelSomePropertyChanged(object sender, EventArgs e)
{
//что-то делаем...
}
}
Вероятно, логика модели должна быть отделена от логики вьюмоделей. При этом встаёт вопрос - а как будут общаться модели разных секций?
Разные секции получились в результате дробления главной модели и они должны быт
между собой связаны? Вероятно лучше оставить одну большую модель, нежели раздробить ее на мелкие. Тот случай когда дробление больше усложняет чем упрощает.
Ответ 3
Вариант 1. Бывает, что трудность реализации вызвана проблемой X/Y. Заказчик, име
проблему X, но не обладая техническими знаниями, предполагает, что эта проблема решаетс
способом Y и просит сделать Y. Программист делает Y, но этот способ сложный, плохо ложится на парадигму реляционных данных, или объектов, или другую известную парадигму. Оказывается, что если попросить программиста решить задачу X, он предложит другое решение, гораздо более простое.
Типичный пример: медленные запросы к базе. Если спросить человека, который проси
отчёт на 30 страниц, что он с ним будет делать, окажется, что он планирует просматриват
его глазами в поисках каких-то важных данных. Но с этой работой гораздо лучше справляется компьютер, и для этого нужен другой запрос, гораздо более простой и производительный.
Поэтому первая рекомендация: ещё раз рассмотреть сценарии использования. Возможно, вместо одного сложного приложения лучше написать три простых для трёх разных ролей.
Вариант 2. Циклы в зависимостях возникают при попытке «формализовать неформализуемое»
Предположим, есть поле A, от которого зависит поле B, а от того, в свою очередь — пол
C. Построим направленный граф, и, если он ациклический, то формально мы можем построить модель и протестировать её. Но если вдруг поле A зависит от C, граф циклический, и у нас проблема. Уточню, что ациклический граф, это не всегда дерево, в нём ветки могут и сходиться, но они не образуют циклов.
Один из способов решения этой проблемы предложил Купер в своей книге «Психбольниц
в руках пациентов». Вы не пересчитываете C при изменении A, а проверяете попадание
диапазон. Если не попали, показываете это пользователю, но не запрещаете дальнейше
редактирование. Похожим образом действует проверка правописания: вы видите те слова, которые не известны программе, но сами решаете, ошибается программа, или нет. Если программа ошибается, вы просто игнорируете то, что подчёркнуто красной чертой. Но опечатку вы не пропустите.
Вариант 3. Подходящий инструмент. Бывает и так, что правила вполне можно формализовать
но сам язык не очень подходит для решения этой задачи. Часто нужно что-то вроде Пролога. Решением здесь являются кодогенерация и предметно-ориентированные языки (domain specific languages, DSL), которые можно использовать и в паре.
Например, вы можете описать бизнес-правила на языке собственной разработки, и пр
сборке проекта компилировать его в основной язык проекта. В современных ОО-языках наподобие C++, Java, C# хорошим решением является так называемая текучая нотация (fluent syntax), когда цепочка методов записывается через точку.
Скажем, в Entity Framework такой своеобразный DSL используется для описания отображений из БД в объекты:
public void Configure(EntityTypeBuilder builder)
{
builder.HasOne(x => x.Order)
.WithMany(x => x.Operations)
.HasForeignKey(x => x.OrderId)
.OnDelete(DeleteBehavior.Restrict);
}
Здесь с помощью цепочки методов строится модель, которая может быть даже формально проверена специально написанным методом.
Эта тема подробно освещена Фаулером сотоварищи в книге о предметно-ориентированных языках.
Комментариев нет:
Отправить комментарий