середа, 13 жовтня 2010 р.

MVC pattern in Delphi

MVC pattern in Delphi (Model-view-controller , "Модель-представление-поведение", "Модель-представление-контроллер").

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



Для начала, что бы определится с терминами, процитирую описание шаблона:
Шаблон MVC позволяет разделить данные, представление и обработку действий пользователя на три отдельных компонента
  • Модель (Model). Модель предоставляет данные (обычно для View), а также реагирует на запросы (обычно от контроллера), изменяя своё состояние.
  • Представление (View). Отвечает за отображение информации (пользовательский интерфейс).
  • Поведение (Controller). Интерпретирует данные, введённые пользователем, и информирует модель и представление о необходимости соответствующей реакции.
Важно отметить, что как представление, так и поведение зависят от модели. Однако модель не зависит ни от представления, ни от поведения. Это одно из ключевых достоинств подобного разделения. Оно позволяет строить модель независимо от визуального представления, а также создавать несколько различных представлений для одной модели.
Первая реализация MVC, которую я увидел, придя в довольно большой проект, выглядела классически - на каждый элемент шаблона была своя иерархия самописных классов, которые стыковались между собой в соответствии с логикой шаблона и приложения. Все хорошо работало, разделение кода было на высоте, но был момент, который несколько огорчал - в контроллерах, работавших с базой данных все датасэты и привязки к данным нужно было создавать/прописывать руками. Если вспомнить, что Delphi - это RAD средство, призванное максимально уменьшить количество типового кода, то этот факт огорчал вдвойне ;) Для приложений, не работающих с БД, я продолжал использовать такой вариант реализации абсолютно без проблем. Но пару лет назад, после 3-х летнего перерыва опять пришлось писать приложение, работающее преимущественно с базой. Пришлось задумался, как не потерять возможность работы в редаторе для настройки интерфейса и биндингов, при использовании шаблонов. Из нескольких вариантов у меня победила реализация MVC c использованием фреймов (TFrame).
Итак, описание моей реализации MVC для Delphi:
  1. Модель (Model) - тут ничего не меняется - это либо данные из БД, либо какой-то класс.
  2. Поведение (Controller) - базовый контроллер - наследник от TFrame.
  3. Представление (View) - базовое представление - наследник от фрэйма-Controller'а.
Дальше, наверно, будет лучше с примером. Допустим, у нас есть сотрудники(employee), и отделы(departments) в которых они работают.
Иерархия классов будет выглядеть так:
  • Контроллеры
TFrame -> TFraBase -> TFraEmployeeBase
                                  -> TFraDepartmentsBase
  • Представление - вот тут появляется нюанс, из-за которого я все это и пишу ;) а то знаете ли объяснять одно и то же много раз таки накладно ;) Поскольку было большое желание оставить все плюшки работы в IDE появился такой нюанс - некоторые представления(View) - наследники описанных контроллеров, НО в них - ни единой строки бизнес-логики, только код, отвечающий за GUI:

TFraEmployeeBase(Controller)     -> TFraEmployee(View, работа с 1-й записью, например на каждое поле - свой Edit)
                                                     -> TFraEmployeeTbl(View, работа в табличном виде)
TFraDepartmentsBase(Controller) -> TFraDepartments(View, работа с 1-й записью)
                                                     -> TFraDepartmentsTbl(View, работа в табличном виде)
ну и 2-й вид View, собственно окна, которые видит пользователь
TForm -> TFrmBase -> TFrmEmployeeList(допустим, просто список служащих - тогда содержит TFraEmployeeTbl)
                                 -> TFrmDepartmentsList(если просто список отделов- тогда содержит TFraDepartmentsTbl)
                                 -> TFrmEmployeeByDepartments(список служащих по отделам - TFraDepartmentsTbl и TFraEmployeeTbl, связанные как мастер-детали)

Теперь попробую объяснить, зачем это было городить ;)
  1. Поскольку контроллер - фрэйм - все датабиндинги настраиваются в IDE, что не сравнить с ручным созданием всей этой обвязки.
  2. Поскольку конкретное отображение конкретного куска биснес-логики лежит на фрейме-наследнике, то привязки к гридам(Grid) и эдитам(Edit) делается в IDE так, что многие завидуют - МЫШКОЙ ;)
  3. С учетом п.1 и 2 на конечном представление - на форме лежат фреймы, между которыми можно делать, например связи мастер-детали прямо из IDE. В итоге в модуле формы только код по инициализации фремов, возможно еще несколько строк по взимодействию между фреймами, но ВООБЩЕ НЕТ бизенс-логики - она вся во фреймах *Base.
  4. Такая реализация дает возможность очень быстро менять вид конечной формы - накидали нужных фреймов, связали между собой - все новый вариант отображения ;) В общем, форму выкинуть не жалко - кода там почти нет :)
  5. В умных книгах регулярно пишут, что злоупотреблений наследованием - это недоработка в вашей архитектуре. При описанной схеме максимальный уровень наследования контроллеров 4-5, обычный 2-3 и не растет даже призначительных переделках. Про отображение можно сказать тоже самое.
Также очень легко было поддерживать минимальное кол-ва связей между разными кусками кода. Конечная иерархия классов в реальном проекте - строго древовидная, никаких перекрестных связей. Каждый фрейм знает только про своих предков и ничего не знает про другие Controller/View. Все связи между фреймами появляются  только на форме.

Не раз приходилось видеть, как люди с "альтернативным мышлением" ;) в проектах рисовали иерархию классов, основываясь на визуальном поведении, а не бизнес логике. Этот кошмар часто выглядел  примерно так:
1-но табличный Справочник -> 2-х табличный Справочник ->...
Потом к этому ужасу пишут изощренные инициализаторы, что бы это работало с разной бизнес логикой, а когда не получается - ветвят формы и через полгодна мало кто до конца помнит, почему восемь 2-х табличных справочников и пять 3-х табличных ;)
А теперь сравним, что произойдет, когда нужно будет добавить, 1 поле к таблице сотрудников из описанного выше примера (сотрудники и отделы). У альтернативщиков это целая РАБОТА - надо же не поломать соседнюю бизнес-логику ;) Унас - поправить TFraEmployeeBase(код), если нужно отображение добавить - то поправить TFraEmployee и TFraEmployeeTbl(мышкой ;) и все - сколько бы ни было форм, где идет работа с сотрудниками, изменения произойдут везде.

Сразу хочется ответить на типичные вопросы, которые не раз слышал от всяких неучей ;) :
  1. Delphi живее всех живых.
  2. Фрэймы не зло - вы просто не умеете их готовить ;)
  3. Все получается быстро и удобно.

Ну и конце, таки попробую найти недостатки ;)
  1. Ну, на начальном этапе, пока вся обвязка только пишется, первые пол дня вы не сможете показать форму с кнопкой "Счастье" (хотя, когда обвязка готова, то в рамках такой реализации, "Счастье" будет появляться очень быстро ;)
  2. Единственный реальный недостаток - на формах в фреймами иногда подглючивает редатор и в dfm может попасть мусор, вплоть до того, что форма перестанет открыватся в IDE. Но ведь все пользуются хорошей системой контроля версий, делают регулярные коммиты и умеют читать/править dfm без IDE, правда? :)
Скачать пример

8 коментарів:

  1. А где, собственно, примерчик? :-)

    ВідповістиВидалити
  2. добавил пример. в нем идея с фреймами, думаю, будет ясна. в реальном приложении нужна будет и не которая иерархия форм, но раскрытие этого вопроса пока отложено ;) так что интересующиеся могут проявить творческий подход :)

    ВідповістиВидалити
    Відповіді
    1. Спасибо, поучительно.
      Еще бы примерчик MVC по работе с TEdit-ами

      Видалити
    2. > Спасибо, поучительно.
      > Еще бы примерчик MVC по работе с TEdit-ами

      Ну, собственно, а кто мешает по такой же схеме работать с обычными контролами? Вариантов на самом деле масса - можно реализовать "классический вариант" MVC, где Model и Controller будут наследниками TObject, а View может начинатся от TFrame.

      Но мой любимый вариант - отказаться от TEdit и других подобных контролов для сколько-нибудь сложной логики :)
      Суть в том что если нет базы данных, то ее можно сделать :) Хотя бы с использованием MemoryTable компонентов (например dxMemtable или kbmMemtable). Тогда задача сводится к предыдущей ;)

      Как в анекдоте:
      Чем физик отличается от математика? Проведем эксперимент. Возьмем простую задачу: есть чайник, плита, кружка и пакетик чая, необходимо заварить чай. Физик решает эту задачу следующим образом: берет чайник, наливает в него воду, ставит чайник на плиту, включает её и ждет, пока закипит вода. После этого он кладет пакетик чая в кружку и наливает туда кипяток. Задача решена. Математик поступает абсолютно аналогичным образом. Теперь немножко упростим нашу задачу: чайник уже содержит кипяток. Физик берет кружку, кладет в неё пакетик чая, наливает кипяток. Математик берет чайник, выливает из него воду и говорит, что задача сводится к предыдущей.

      Видалити
  3. Интересный вариант. Очень долго искал как прикрутить MVC к Борландовским IDE. Похоже вы чуть ли не единственный, кто показал это на примере.
    Но у меня вопрос. Обработчики событий где реализуются? В том же фрейме, где находятся сами объекты?
    Как в вашей модели можно реализовать, например обработчик грида OnDrawColumnCell который в определённой колонке должен отображать данные, получаемые из другого датасета? Где его размещать? В FraGrid?
    А если тоже самое надо отоброжать в соседнем гриде (т.е. грид другой и отображает другую таблицу, но там тоже есть такая колонка). Получается логику вычисления значения поля нужно поднимать до контроллера?

    ВідповістиВидалити
    Відповіді
    1. Не до конца понял проблему, но попробую ответить...
      В OnDrawColumnCell ничего кроме "рисования" не должно быть. А вся визуализация в моем примере внутри View, так что да этому обработчику место на FraXXXGrid. Более того, Grid'а в контроллере быть не должно.

      По поводу
      > Получается логику вычисления значения поля нужно
      > поднимать до контроллера?
      Ответ тоже да, но если речь о Calculated Field, то крайне советую от них отказаться и, если используется сколько-нибудь нормальная БД, попытаться перенести эти вычисления в запрос получения данных. По моим замерам будет видна разница по скорости уже на 5-7тыс записей.

      Также в примере нет ответа на вопрос - как раскрасить все гриды/контролы одинаково.
      Мы использовали такой вариант
      - объявляем интефейс, допустим, IColoredGrid
      - на всех FraXXXGrid где нужна раскрсака, реализовывается его поддержка
      - при создание формы в runtime пробегаем по фреймам и, если интерфейс поддерживается, цепляем к нему обработчик

      Видалити
  4. Здравствуйте. У меня ещё пара вопросов.
    1. Как я понял по вашему примеру, контроллеров в иерархии может быть несколько? TFraBase - базовый контроллер, реаизующий некую общую логику. И частные реализации (TFraOrdersBase например. К которым пристёгнута модель в виде датасета ?
    2. Что такое тогда TFraBaseDB? просто переходник для доступа к модели из базового контроллера?
    3. Как фреймы общаются друг с дтугом? Это зашито в форму, или может быть они обмениваются сообщениями? Или каким другим способом? Например через статические члены базового класса контроллера?
    У вас в примере есть фрейм с кнопкой, но не показано как его использовать.
    4. Как ваш подход уживается с юниттестированием? Ну т.е. как тестировать базовый контроллер мне понятно, т.к. он ни от чего не зависит.
    А как тестировать частные контроллеры типа TFraOrdersBase? В них же вшиты компоненты доступа к бд. Экранировать все обращения к этим компонентам через виртуальные методы, чтобы в тестах их можно было забить заглушками?

    Заранее спасибо за ответы.

    ВідповістиВидалити
    Відповіді
    1. 1,2 - вроде бы все правильно, но TFraBase в примере еще не контроллер. TFraBaseDB - базовый контроллер и "просто переходник для доступа к модели".
      3. фреймы тут разные... но общее то, что controller или его view НИКАК НЕ ОБЩАЮТСЯ с другими controller/view до попадания на форму(тогда их можно связать в design time, например, как master-detail) или в другой код где они связываются в runtime. это просто правило которое поддерживается исключительно как договоренность на уровне проекта(культура кода, если говорить более высокопарно ;) это очень помогает когда за год код разросся - вы получаете свой мини фреймворк для вашего проекта. так сказать куча упорядоченных кирпичиков, которые заточены под вашу предметную базу. обмен сообщениями или просто дергать ивенты - это уже не так важно... важно минимизировать количество связей.
      4. как уже упоминал - юниттесты хорошо работают при такой схеме. тестирование контроллеров работать будет и без gui.
      что тестировать во вью отдельный вопрос... например я мерял скорострельность gui :) в результате отказался от calculated fields.

      вопрос по тестам уже был... попробую найти время и добавить их в пример.

      Видалити