Статьи

Соображения о дизайне Grails # 2 — добавьте модель вида время от времени — Блог Теда Винке

Это мое мнение о том, как мы могли бы спроектировать наш конкретный пользовательский интерфейс таким образом, чтобы он мог повторно использоваться, тестироваться и в целом программное обеспечение было более удобным в обслуживании. Да, используя немного 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занимая много места .

Небольшой фрагмент из 1000+ строк HTML: Hello SVG

Небольшой фрагмент из 1000+ строк HTML: Hello 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 ), если разные контроллеры создают разные экземпляры для разных целей. Что работает лучше всего, зависит от варианта использования — как всегда.

Grails-проблемно-браузер

Вуаля!

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

Конечно нет. Возьмите, например, слайдер.

Grails-слайдер

Если это займет всего несколько атрибутов, таких как 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? В следующем посте я опишу, как протестировать нашу новую библиотеку тегов.

дальнейшее чтение