Это мое мнение о том, как мы могли бы спроектировать наш конкретный пользовательский интерфейс таким образом, чтобы он мог повторно использоваться, тестироваться и в целом программное обеспечение было более удобным в обслуживании. Да, используя немного View Models из шаблона MVVM.
Фон
Недавно мы начали работать с двумя командами над новым приложением Grails.
В рамках этого я рассмотрел некоторый код более ранних приложений команд, чтобы прийти к некоторым общим стандартам и соглашениям по кодированию, которые обе команды будут использовать для нового приложения . В этих сеансах обзора кода я также даю советы по поводу некоторых архитектурных решений и стилей дизайна, которые я бы предпочел.
Вариант использования: задачи
На домашней странице нового приложения отображается зарегистрированный пользователь, обзор задач, сгруппированных по месяцам, и несколько несвязанных ползунков.
Например, что-то вроде этого:
Мы уже создали хороший основной макет (grails-app / views / layouts / main.gsp) с заголовком, блоком контента и нижним колонтитулом. В первые несколько дней спринта, когда первоначальный дизайн и ресурсы были также добавлены в приложение Grails, для реализации этого обзора велась история, скажем, с членом команды по имени Джон.
Для этого Джон создал контроллер (HomeController) и GSP (home / index.gsp). Верхний и нижний колонтитулы уже были извлечены в основной макет, поэтому изначально пустой GSP…
|
1
2
3
4
5
6
7
8
9
|
<%@ page contentType="text/html;charset=UTF-8"%><html> <head> <meta name="layout" content="main" /> <title>Tasks</title> </head> <body> </body></html> |
… Быстро начал заполняться довольно сложным HTML. Несмотря на то, что приведенный выше абстрактный скриншот говорит вам, обзор реальных задач содержал довольно много изображений SVG — занимая много места .
После завершения обзора задач появятся несколько ползунков.
Если вы когда-либо получали статический HTML от дизайнерского агентства, вы уже испытали это, но в какой-то момент вы должны заставить материал работать с реальным контентом, а не с обычным lorem ipsum .
Обзор задач показывает задачи, сгруппированные по месяцам (январь, февраль, матч и т. Д.) Текущего года. Представление, однако, не всегда должно начинаться в январе, но в текущем месяце, поэтому самые неотложные задачи стоят на первом месте. Это как минимум требование на данный момент.
Вторым шагом Джона было перенести жестко запрограммированные значения из HTML в само приложение и предоставить ему путь Grails к GSP; через контроллер в модели.
HomeController выглядел примерно так:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
class HomeController { def securityService def taskService def index() { int showPercentage = 3 Map<String, List> tasks = retrieveTasks() Map<String, Integer> notifications = retrieveNotifications() def nowDate = new Date() def currentMonth = nowDate[Calendar.MONTH] + 1 return [ username: securityService.user.name, tasks : tasks, showPercentage: showPercentage, months : getMonths(), startMonth : currentMonth, notifications : notifications ] } private Map<String, List> retrieveTasks() { taskService.retrieveTasks() } private Map<String, Integer> retrieveNotifications() { taskService.retrieveNotifications() } List<String> getMonths() { return [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ] }} |
Модель имела имя пользователя (для отображения «Привет, Джон») и различные переменные ( tasks , showPercentage , months , startMonth , notifications ), необходимые для обзора задач и ползунков внизу.
Следовательно, index.gsp был обновлен для получения значений из модели.
|
1
2
3
4
5
6
7
|
<body> <div class="container"> <div class="pat-well"> <div class="row month-${startMonth}" id="task-browser"> <div class="six columns"> <g:if test="${notifications}"> ... |
Итак, это начальная версия, и она работает.
Model-View-ViewModel
Глядя на код контроллера и то, что в конечном итоге в модели, нельзя легко сказать, что связано, а что нет.
|
1
2
3
4
5
6
7
8
|
return [ username: securityService.user.name, tasks : tasks, showPercentage: showPercentage, months : getMonths(), startMonth : currentMonth, notifications : notifications ] |
Также переменные помещаются в модель в случайном порядке — возможно, в том порядке, в котором они были созданы, необходимы в GSP или чем-то еще. Эти массовые модели не являются редкостью — и это всего лишь «возвращенные» 6 вещей — для различных частей пользовательского интерфейса.
Может быть, мы сможем немного это исправить.
Часть 1 — Просмотр модели
Давайте представим модель представления, простой старый объект Groovy (POGO) только для хранения связанных данных для «компонента» представления. Привет, это первый раз, когда я слышу, как ты упоминаешь компонент . Да, это один из тех перегруженных терминов, которые могут означать что угодно. Это удачно, потому что мы можем создать POGO для чего угодно ?
Создайте новый класс в нижней части HomeController.groovy и назовите его в соответствии с тем, что представляет ваш виджет или компонент пользовательского интерфейса. До сих пор я продолжал говорить «обзор задач» и мог вызывать новый класс: TasksOverview .
В этом конкретном случае мы получаем макет от внешнего агентства веб-дизайна, и они обычно также думают о присвоении имен объектам для использования в качестве селекторов CSS или объектов JavaScript. Взгляд в источнике показал:
|
1
|
<div class="row.." id="task-browser"> |
Задача браузера!
Особенно когда в проекте, где используется много элементов пользовательского интерфейса, важно на ранних этапах называть вещи между командами разработчиков и разработчиков. Вы просто не можете продолжать говорить, что песочные часы в верхнем правом углу .
Класс TaskBrowser также может быть отдельным TaskBrowser.groovy в папке src , но сейчас он используется только на домашней странице, так что просто поместите его ниже класса HomeController.groovy в HomeController.groovy .
|
1
2
3
4
5
6
7
8
9
|
...}/** * Task browser. */class TaskBrowser { } |
Переместите каждое связанное свойство, которое содержит данные из действия index () контроллера, в этот новый класс, например, коллекции tasks и notifications .
|
1
2
3
4
|
class TaskBrowser { Map<String, List> tasks = [:] Map<String, List> notifications = [:]} |
Создайте экземпляр TaskBrowser, либо в самом действии, либо с помощью вспомогательного метода, например
|
1
2
3
4
5
6
|
private TaskBrowser createTaskBrowser() { return new TaskBrowser( tasks: taskService.retrieveTasks(), notifications: taskService.retrieveNotifications() )} |
У нас все еще остается некоторая обработка даты / времени для составления списка месяцев и определения начального месяца для браузера задач.
Хотя этот класс кажется просто пакетом связанных данных, не забывайте о надлежащих принципах ОО и перенесите эту ответственность также на класс компонентов, если это возможно, например
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
class TaskBrowser { Map<String, List> tasks = [:] Map<String, List> notifications = [:] int getStartMonth() { def nowDate = new Date() nowDate[Calendar.MONTH] + 1 } List<String> getMonths() { return [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ] }} |
Действие контроллера теперь может возвращать TaskBrowser в модели, который не только урезан до «только 3 вещей», но и более ясно, что связано.
|
01
02
03
04
05
06
07
08
09
10
11
12
|
def index() { int showPercentage = 3 return [ username: securityService.user.name, showPercentage: showPercentage, taskBrowser: createTaskBrowser() ]}private TaskBrowser createTaskBrowser() { ...} |
Часть 2 — Вид
TaskBrowser , но это еще не все: вещи из TaskBrowser по-прежнему используются в различных частях home / index.gsp — в основном изменения нужно делать повсеместно .
Помните, что наш index.gsp представляет собой смесь с общим HTML-кодом, перетекающим в определенный компонент, такой как браузер задач:
|
1
2
3
4
5
6
7
|
<body> <div class="container"> <div class="content"> <div class="row month-${startMonth}" id="task-browser"> <div class="six columns"> <g:if test="${notifications}"> ... |
В нашей текущей настройке важно, чтобы
- кто бы ни работал над браузером задач, заставляя его работать с реальными данными из системы
- кто бы ни внедрял изменения пользовательского интерфейса в браузере задач, поскольку от веб-дизайнера приходят твики
- кто бы ни работал над другими функциями домашней страницы
не сталкивайся слишком много.
Это означает, что они не должны много работать над одними и теми же строками кода в приложении, если мы можем предотвратить это. И мы можем.
шаблон
Самая простая вещь, о которой можно подумать, это извлечь большой кусок HTML из GSP в отдельный фрагмент или шаблон .
Извлеките HTML из index.gsp в меньший GSP, называемый, например, _taskBrowser.gsp , который в конечном итоге может быть включен, как в index.gsp с помощью <g:render>
|
1
2
3
4
|
<body> <div class="container"> <div class="content"> <g:render template="taskBrowser" /> |
Это делает красивый и чисто изолированный HTML в шаблоне, такой как
|
1
2
3
|
<div class="row month-${startMonth}" id="task-browser"> <div class="six columns"> <g:if test="${notifications}"> |
Кажется, все в порядке, пока вы не поймете, что вам все еще нужно передать модель в шаблон, прежде чем шаблон сможет получить доступ ко всем необходимым переменным. До тех пор не очень полезно.
|
1
2
3
4
5
6
7
8
9
|
<body> <div class="container"> <div class="content"> <g:render template="taskBrowser" model="[ tasks : taskBrowser.tasks, months : taskBrowser.months, startMonth : taskBrowser.startMonth, notifications : taskBrowser.notifications ]" /> |
Немного многословно. Более короткая версия будет использовать атрибут bean .
|
1
|
<g:render template="taskBrowser" bean="${taskBrowser}" /> |
В этом случае мы не можем получить доступ к свойствам из taskBrowser пока не изменим шаблон, чтобы получить все от неявного it .
|
1
2
3
|
<div class="row month-${it.startMonth}" id="task-browser"> <div class="six columns"> <g:if test="${it.notifications}"> |
Это компромисс, но мы немного упростили жизнь автора index.gsp, просто для этого потребовались две вещи: имя шаблона и данные .
Имя включаемого шаблона все еще может рассматриваться как деталь реализации. Переименование или его замена полностью включает обновление всех GSP, которые могут отображать этот конкретный шаблон по имени , и, таким образом, — если у вас есть больше таких шаблонов — может стать бременем обслуживания.
Посмотрим, сможем ли мы добиться большего успеха с…
Библиотека тегов
Слой View приложения Grails — это не только Groovy Server Pages (GSP) в каталоге grails-app / views-directory, но также включает механизм библиотеки тегов Grails. Библиотека тегов является своего рода «помощником вида» в модели контроллера вида модели (MVC).
Создайте библиотеку тегов, которая называется, например, LayoutTagLib в grails-app / taglib, которая помогает размещать элементы «макета» на странице. Довольно общее имя, но его всегда можно разделить на более конкретные библиотеки вкладок, если оно становится слишком большим.
|
1
2
3
4
5
6
7
|
/** * Layout tags. */class LayoutTagLib { static defaultEncodeAs = [taglib:'html']} |
taskBrowser тег с именем taskBrowser который принимает один атрибут taskBrowser — который требуется. Тело не нужно для этого.
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
|
/** * Layout tags. */class LayoutTagLib { static defaultEncodeAs = [taglib:'html'] /** * Renders the task browser. * * @attr taskBrowser REQUIRED a task browser instance */ def taskBrowser = { attrs -> if (!attrs.taskBrowser) { throwTagError("Tag [taskBrowser] is missing required attribute [taskBrowser]") } }} |
throwTagErrorсгенерируетGrailsTagExceptionкоторомGrailsTagExceptionуказано, чего не хватает, если пользователь тега забудет передать необходимые атрибуты. Часть «@attr REQUIRED» предназначена для поддержки и / или документации IDE.
Рендеринг предыдущего шаблона дает правильную модель. Поскольку _taskBrowser.gsp теперь становится обязанностью этого тега lib, переместите его в новое, более нейтральное местоположение, такое как, например, grails-app / views / layouts / components.
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
/** * Layout tags. */class LayoutTagLib { static defaultEncodeAs = [taglib:'none'] /** * Renders the task browser. * * @attr taskBrowser REQUIRED a task browser instance */ def taskBrowser = { attrs -> if (!attrs.taskBrowser) { throwTagError("Tag [taskBrowser] is missing " + "required attribute [taskBrowser]") } TaskBrowser browser = attrs.taskBrowser out << render(template: '/layouts/components/taskBrowser', model: [tasks : browser.tasks, months : browser.months, startMonth : browser.startMonth, notifications : browser.notifications ]) }} |
defaultEncodeAs = [taglib:'html']на[taglib:'none']— чтобы не допустить кодирования нашего отсканированного фрагмента GSP в HTML.
Джип, мы все еще передаем каждую отдельную часть модели здесь, но, конечно, вы также можете использовать вариант bean если хотите.
Индексная страница теперь становится:
|
1
2
3
4
|
<body> <div class="container"> <div class="content"> <g:taskBrowser taskBrowser="${taskBrowser}"/> |
Это помогает TaskBrowser как вход для тега taskBrowser . Поскольку сейчас мы находимся в мире библиотек тегов, мы можем выполнить некоторую дополнительную логику или обработку на основе этого ввода (например, сначала отфильтровать задачи, которые будут отображаться по определенному типу задач) и передать обработанные данные дальше в шаблон в модели.
Если такой дополнительный ввод, например, для фильтра, является необязательным и зависит от места в приложении, где используется тег, вы можете принять его в качестве необязательного атрибута вместо размещения его в объекте TaskBrowser .
Например, дополнительный атрибут filter может работать как
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
/** * Renders the task browser. * * @attr taskBrowser REQUIRED a task browser instance * @attr filter Optionally a {@link Task.Type} to show only those tasks */def taskBrowser = { attrs -> if (!attrs.taskBrowser) { throwTagError("Tag [taskBrowser] is missing " + "required attribute [taskBrowser]") } TaskBrowser browser = attrs.taskBrowser // filter tasks by type def tasks = browser.tasks if (attrs.filter && attrs.filter instanceof Task.Type) { tasks = browser.tasks.findAll { task -> task.type == attrs.filter } } out << render(template: '/layouts/components/taskBrowser', model: [tasks : tasks, months : browser.months, startMonth : browser.startMonth, notifications : browser.notifications ])} |
Страница может игнорировать атрибут:
|
1
|
<g:taskBrowser taskBrowser="${taskBrowser}" /> |
Другая страница может использовать фильтр:
|
1
|
<g:taskBrowser taskBrowser="${taskBrowser}" filter="${TaskType.PERSONAL}" /> |
Опять же, это только один из способов. Несомненно, другим подходящим подходом может быть размещение такого рода вещей (например, параметров фильтра) в исходной модели представления (класс TaskBrowser ), если разные контроллеры создают разные экземпляры для разных целей. Что работает лучше всего, зависит от варианта использования — как всегда.
Вуаля!
Должен ли я создать конкретную модель представления для всего?
Конечно нет. Возьмите, например, слайдер.
Если это займет всего несколько атрибутов, таких как value ниже …
|
1
|
<g:slider value="${showPercentage}" /> |
Казалось бы, showPercentage оборачивать / получать showPercentage в / из специализированного объекта. Просто передайте такие значения непосредственно в GSP.
|
1
2
3
4
5
6
7
|
class HomeController { def index() { ... return [..., showPercentage: 3] } |
Помните, что мы начали с контроллера, который возвращал большую загроможденную модель со всеми видами вещей там?
|
1
2
3
4
5
6
7
8
|
return [ username: securityService.user.name, tasks : tasks, showPercentage: showPercentage, months : getMonths(), startMonth : currentMonth, notifications : notifications ] |
Затем мы сгруппировали связанные вещи в View Models, чтобы увидеть, что принадлежит друг другу. Если вы обнаружите, что в модели все еще есть дюжина «простых значений», это может быть сигналом того, что все еще можно найти какие-то отношения, кандидатов, которые в конце концов будут сгруппированы.
Разве мои классы доменов уже не являются моделью представления?
Конечно. Для экранов CRUD информация, которую вы должны отобразить, в основном соответствует информации, которую вы имеете в своем классе домена. Большую часть времени. Ладно, не совсем верно для более сложных административных фронт-эндов или бэкэндов, но это то, что кажется, если вы посмотрите на некоторые контроллеры, которые работают идеально для простых сценариев.
Я написал более раннюю статью о некоторых вещах, ориентированных на классы домена, о которых нужно знать, даже в простых случаях ?
Должен ли мой тег всегда отображать какой-то шаблон?
Конечно, нет.
Как вы можете видеть из
|
01
02
03
04
05
06
07
08
09
10
11
12
|
def taskBrowser = { attrs -> ... TaskBrowser browser = attrs.taskBrowser // filter tasks by type def tasks = browser.tasks if (attrs.filter && attrs.filter instanceof Task.Type) { tasks = browser.tasks.findAll { task -> task.type == attrs.filter } } out << render(template: '/layouts/components/taskBrowser', model: [...]) |
логика фильтрации выполняется в самом теге, но отображение более 1000 строк HTML делегировано в _taskBrowser.gsp . Это естественно подходит для больших кусков HTML, GSP.
Можно делать небольшие HTML-вещи в самом теге, например
|
1
2
3
|
def importantMonth = { attrs -> out << "<strong>${attrs.month}</strong>"} |
HTML против Code-Centric
Здесь определенно есть серая область с библиотеками тегов .
Следующий пример тега больше ориентирован на код с промежуточным HTML.
|
1
2
3
4
5
6
7
|
def showMonths = { attrs -> out << "<ol>" months.each { month -> out << "<li>${month}</li>" } out << "</ol>"} |
Как вы можете видеть, становится все труднее увидеть, что происходит, поскольку код пронизан потоком управления и выводом HTML. Если бы вы выбрали более HTML-ориентированный подход, вы бы получили такой фрагмент кода GSP:
|
1
2
3
4
5
|
<ol><g:each var="month" in="${months}"> <li>${month}</li></g:each></ol> |
В нашем случае, когда нам нужно более 1000 строк HTML-кода какого-либо сложного компонента пользовательского интерфейса обозревателя задач, было бы лучше поместить этот HTML-код в шаблон ?
В заключении
Успешное разделение задач с использованием модели представления и библиотеки тегов позволяет продумывать компоненты, что может привести к более удобному для сопровождения коду, поскольку части, которые работают вместе и должны меняться вместе, находятся в четких, изолированных и различимых местах в базе кода.
Надеюсь, что хотя бы мыслительный процесс может вам немного помочь, если вы окажетесь в такой же ситуации, как и мы.
Подумайте о том, чтобы добавить модель взгляда время от времени ?
Вы говорите о Testabillity? В следующем посте я опишу, как протестировать нашу новую библиотеку тегов.
дальнейшее чтение
- Библиотеки тегов — Документация Grails
- Доменные классы Grails и особые требования к презентации —
Блог Теда Винке - Модель – вид – вид-модель — Википедия


