DSL на Scala для работы с Нtml-формами

в 7:53, , рубрики: dsl, play framework, scala, Блог компании Naumen, метки: , ,

DSL на Scala для работы с Нtml формами

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

  • определение свойств полей формы, таких как тип, заголовок или валидность;
  • обработка данных формы после ее отправки;
  • рендеринг формы.

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

В этой статье я опишу процесс создания собственного DSL на языке Scala, а затем покажу, как новый способ описания форм применить в контексте Play Framework 2.

Немного о терминологии.

В этой статье я говорю о внутреннем (internal) DSL. Внутренний DSL — это не новый язык, а только удобный способ описания какой-либо предметной области с использованием синтаксиса основного (host) языка программирования. Правда, если синтаксиc host-языка достаточно гибок, внутренний DSL может выглядеть так, будто это новый язык, предназначенный для данной области. К плюсам такого варианта можно отнести то, что среды разработки понимают внутренний DSL, подсвечивают синтаксис, предлагают варианты автодополнения. Для сравнения, внешний DSL — это действительно новый язык, которому нужен свой парсер.

Описанный в статье DSL изначально создавался для решения проблем, которые возникали из-за некоторых ограничений движка форм в Play Framework 2. Но сейчас он независим от Play и может быть использован вместе с любым JVM-фреймворком, если реализовать соответствующие адаптеры.

C чего начать разработку DSL?

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

  • почта — текст, обязательное, валидация на корректность почтового адреса;
  • имя — текст, обязательное;
  • дата рождения — дата, необязательное.

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

form(
  string(email, required, validate(EmailAddress))
  string(name, required)
  date(birthDate)
) 

Теперь пришло время подумать, как ее реализовать. Сперва рассмотрим общее описание формы, а именно набор полей и их тип. Scala предоставляет нам несколько подходов для создания такого описания:

1. Builder pattern and method chaining

Сигнатура фабричного метода для формы:

def form(builderFoo: FormBuilder => FormBuilder)

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

form(_
  .string(...)
  .string(...)
  .extend(commonFields)
  .date(...)
)

Здесь мы используем паттерны builder и method chaining. Это дает нам следующие преимущества:

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

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

2. Как функцию с переменным числом параметров

Фабричный метод:

def form(fields: FormBuilder => FormBuilder*)

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

form(
  _.string(...),
  _.string(...),
  _.date(...),
  commonFields:_*
)

У такого варианта есть несколько недостатков:

  • необходимость писать больше вспомогательного кода;
  • дополнительные поля можно добавлять только в конце списка аргументов.

3. Блок кода с последовательностью вызовов

Фабричный метод:

def form(fields: => ())

Использование:
form{
  string(...)
  date(...)
  commonFields()
}

Этот способ плох тем, что:

  • в момент определения формы мы завязаны на конкретные имплементации методов string, date, и т. д.
  • для его реализации нам пришлось бы работать с изменяемым состоянием, чего хотелось бы избежать;
  • нет типизации передаваемых выражений.

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

Описание полей

Для детального описания полей также выбран method chaining и builder, из тех же соображений.
Сигнатура метода определения поля:

def string(fieldFoo: FieldBuilder => FieldBuilder): FormBuilder

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

.string(_.name("email").required.validate(EmailAddress))

Набросок реализации билдера

Для реализации билдера в Scala отлично подходит case class с его автоматически определяемым методом copy. Этот метод копирует текущий объект, модифицируя нужные атрибуты. Билдер для отдельного поля мог бы выглядеть так:

case class FieldBuilderImpl(fieldType: String, label: String, isRequired: Boolean, validators: Seq[Validator])
  extends FieldBuilder {
  def fieldType(t: String): FieldBuilder = copy(fieldType = t)
  def label(l: String): FieldBuilder = copy(label = l)
  def required: FieldBuilder = copy(isRequired = true)
  def validate(vs: Validator*): FieldBuilder = copy(validators = validators ++ vs)
  def build: FieldDescription = ???
}

Основной параметр формы — это набор ее полей, поэтому для нее билдер будет выглядеть несколько иначе:

case class FormBuilderImpl(fields: Map[String, FieldDescription]) extends FormBuilder {
  def field[F](name: String)(foo: FieldBuilder[F] => FieldBuilder[F]) =
	copy(fields = fields ++ Map(name -> foo(newFieldBuilder[F]).asInstanceOf[FieldBuilderImpl[F]].build))
  def string(name: String)(foo: FieldBuilder[String] => FieldBuilder[String]) =
	field[String](name)(foo andThen (_.fieldType("string")))
  def newFieldBuilder[F]: FieldBuilder[F] = ???
  def build: FormDescription = ???
}

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

Представление данных формы

После того как пользователь произвел отправку формы, используемый веб-фреймворк получает данные запроса и преобразует их к собственному framework-specific представлению. Одной из задач интеграции с фреймворком является преобразование его внутреннего представления формы к удобному нам виду (данный процесс кратко описан в конце статьи применительно к Play Framework)

Представлять данные формы удобно в виде экземпляра case class. Для краткости будем называть его «объект данных формы» или просто «данные формы». Поля формы будут представлены типизированными полями этого класса. В этом случае мы можем идентифицировать поля формы замыканием — вызовом геттера нужного поля объекта данных формы (например, замыкание (_.someField) будет соответствовать полю формы с названием "someField").

Передача замыкания вместо строки обладает важным преимуществом — она позволяет нам контролировать тип поля формы, ведь нам известен тип замыкания. Например, по типу UserFormData => String мы можем определить, что поле формы должно быть только строковым.

Получение названия поля с помощью рефлексии

Для получения названия поля в Scala есть, как минимум, два способа: первый, традиционный — с помощью рефлексии, второй — с помощью макросов. Рефлексия хороша тем, что это давно отработанный, довольно неприхотливый механизм, в то время как макросы — фича новая, находящаяся в статусе эксперимента, и есть некоторые ограничения ее использования. Зато они позволяют определить название поля во время компиляции, что, конечно, является существенным плюсом. В данной статье мы ограничимся рефлексией (до макросов доберемся в следующий раз).
Итак, нас интересует возможность определять поле следующим способом:

form[FormData](_.string( _.someField )(_.someProperty))

т.е. по замыканию _.someField получать название "someField".
Функция, позволяющая сделать это, могла бы иметь следующую сигнатуру:
fieldName[T:Manifest](fieldFoo: T => Any): String
Запись [T:Manifest] означает, что к сигнатуре метода будет добавлен дополнительный, implicit-ный список параметров, ожидающий от компилятора аргумент типа Manifest[T]. На самом деле, это не более чем костыль для преодоления такого явления в JVM, как Type Erasure. С манифестом мы получаем возможность получить type parameter (аналог type variable у Generic-класса в Java) во время выполнения.
Получив из манифеста класс, мы конструируем из него динамический прокси proxyObject: объект, удовлетворяющий переданному нами типу, единственная задача которого — сообщить название первого вызванного метода. Если в качестве fieldFoo: T => Any будет передано замыкание (_: T).someField, то вызов fieldFoo(proxyObject) даст нам строку "someField". Эти действия были вынесены в библиотеку scala-reflective-tools.
Ее использование выглядит примерно так:

case class MyClass(fieldA: String)
import FieldNameGetter._
assertTrue $[MyClass](_.fieldA) == "fieldA"

Теперь мы можем переписать билдер формы так:

case class FormBuilderImpl[T:Manifest](fields: Map[String, FieldDescription]) extends FormBuilder[T] with FieldNameGetter {
  def field[F](fieldFoo: T => F)(foo: FieldBuilder[F] => FieldBuilder[F]) =
	copy(fields = fields ++ Map($[T](fieldFoo) -> foo(newFieldBuilder[F]).asInstanceOf[FieldBuilderImpl[F]].build))
  def string(fieldFoo: T => String)(foo: FieldBuilder[String] => FieldBuilder[String]) =
	field[String](fieldFoo)(foo andThen (_.fieldType("string")))
...
}

Поля формы могут быть обязательными и опциональными. Свойство опциональности поля мы можем отразить в case class, дав соответствующему полю тип Option[...].
Например, для нашей формы case class мог бы выглядеть так:

case class RegistrationFormData(
  name: String,
  surname: Option[String],
  email: String,
  birthDate: Option[Date]
)

Геттер поля name имеет тип RegistrationFormData => String, а геттер surnameRegistrationFormData => Option[String]. Соответственно, мы должны иметь два метода для определения полей.
Для обязательных:
(def string(fieldFoo: T => String)(foo: FieldBuilder[String] => FieldBuilder[String])
Для опциональных:
def stringOpt(fieldFoo: T => Option[String])(foo: FieldBuilder[String] => FieldBuilder[String])
Для каждого типа поля (кроме boolean) потребуются оба варианта. Кастомизируем метод string, декларируя в нем свойство required. При помощи каррирования удалось получить довольно компактный код:

case class FormBuilderImpl[T: Manifest](fields: Map[String, FieldDescription]) extends FormBuilder[T] with FieldNameGetter {
//создадим алиасы для громоздких типов:
type FieldFoo[F] = FieldBuilder[F] => FieldBuilder[F]
type FormField[F] = FieldFoo[F] => FormBuilder[T]
//метод для обязательных полей
  def string(fieldFoo: T => String): FormField[String] = fieldBase[String](fieldFoo)(_.required)
//метод для опциональных полей
  def stringOpt(fieldFoo: T => Option[String]): FormField[String] =  fieldBase[String](fieldFoo)(identity)

  def field[F](fieldName: String)(foo: FieldFoo[F]) =
	copy(fields = fields ++ Map(fieldName -> foo(newFieldBuilder[F]).asInstanceOf[FieldBuilderImpl[F]].build))
  def fieldBase[F: Manifest](fieldFoo: T => Any)
	(innerConfigFoo: FieldFoo[F])
	(userConfigFoo: FieldFoo[F])
	= field($[T](fieldFoo))(innerConfigFoo andThen (_.fieldType(fieldTypeBy[F])) andThen userConfigFoo)

...
}

Кроме того, здесь мы определяем свойство fieldType, используя type parameter.

Расширение DSL

На данный момент с помощью нашего FieldBuilder мы можем указывать только свойства label и required, и этого недостаточно. У разработчиков должна быть возможность расширить набор свойств полей, если того требует задача. Кроме того, полям разного типа требуется указывать разные свойства.
Чтобы решить это проблему, мы воспользуемся implicit преобразованиями и паттерном Pimp My Library.

Добавим в FieldBuilder следующий метод:
def addProperty(key: String, value: Any): FieldBuilder[T]
Далее всю конфигурацию поля будем задавать через него. Пользователи нашего DSL не будут обращаться к нему напрямую, пользовательский API составят implicit-ные преобразователи вида:

object FieldDslExtenders {
  import FieldAttributes._
  implicit class StringFieldBuilderExtender(val fb: FieldBuilder[String]) extends AnyVal {
	def minLength(length: Int) = fb.addProperty(MinLength, length)
	def maxLength(length: Int) = fb.addProperty(MaxLength, length)
  }
  implicit class SeqFieldBuilderExtender[A](val fb: FieldBuilder[Seq[A]]) extends AnyVal  {
   ...
  }
  implicit class DateFieldBuilderExtender(val fb: FieldBuilder[Date]) extends AnyVal {
	def format(datePattern: String) = fb.addProperty(DatePattern, datePattern)
  }
}

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

Не все идеально и у нашего FormBuilder: с его помощью пока что можно добавить довольно ограниченный набор типов полей. Понятно, что добавить в него все наиболее распространенные виды не сложно, но что если пользователям потребуется новый тип поля, отсутствующий в FormBuilder? Решить эту проблему можно при помощи все тех же implicit-ных преобразователей, которые обращаются к базовому методу fieldBase. С их помощью мы можем расширять FormBuilder — например, мы могли бы добавить метод создания поля с датой:

object FormDslExtensions extends FieldNameGetter {
  val defaultDateFormat = new SimpleDateFormat("dd.MM.yyyy").toPattern
  import FieldDslExtenders._
  implicit class DateFormExtension[T: Manifest](val fb: FormBuilder[T]) extends AnyVal {
	def dateOpt(fieldFoo: T => Option[Date]) = fb.fieldBase[Date](fieldFoo)(_.format(defaultDateFormat)) _
	def date(fieldFoo: T => Date) = fb.fieldBase[Date](fieldFoo)(_.required.format(defaultDateFormat)) _
  }
}
Внутреннее представление формы

В результате работы наших билдеров мы получаем объект-описание формы FormDescription, содержащий карту String -> FieldDescription. В свою очередь, FieldDescription содержит карту String -> Any. Таким образом, мы можем задать полям любые атрибуты, которые могут потребоваться. Получаемое описание формы, на мой взгляд, вполне framework-agnostic, т.е. может использоваться с различными фреймворками. Все, что нужно — реализовать конвертацию в представление формы используемого фреймворка. Далее мы рассмотрим, как это было сделано для Play Framework.

Интеграция с Play

Play уже имеет механизм, удобный для не очень сложных форм. Но он обладает следующими ограничениями:

  • ограничение в 18 полей;
  • возможность указания только типа полей и валидации;
  • отсутствие полей с множественным выбором (на момент написания статьи, скоро должно появиться).

При сабмите формы, Play получает HTTP-запрос, десериализует его и представляет параметры запроса в виде Map[String, Seq[String]]. Далее их ожидает валидация и конвертация в подходящее представление для пользовательского кода. Встроенные средства Play позволяют сконвертировать эти данные либо в Tuple, либо в произвольный объект — для этого, правда, нужно предоставить функцию его создания. Если форма имеет достаточное количество полей, это может привести к потенциально богатому на ошибки коду. Представьте: вам нужно убедиться, что 18 аргументов функции расположены в правильном порядке.

Для валидации и конвертации «сырых» данных Play использует класс Mapping.
Встроенные в Play маппинги форм получают маппинги полей как отдельные аргументы конструктора (как пример: ObjectMapping9), поэтому форма может иметь только строго фиксированный набор полей, определяемый в момент ее определения. Наш класс, который мы назовем FormMapping, может работать с произвольным количеством полей. С другой стороны, передаваемые в него поля теряют типизацию, но это не страшно, так как FormMapping не предназначен для того, чтобы работать с полями вручную. Типизация полей гарантируется DSL, а конвертация в объект данных формы и обратно происходит автоматически с помощью рефлексии.

Формы в Play представлены с помощью case class play.api.data.Form, а поля — play.api.data.Field. От этих классов будут наследоваться и наши реализации форм и полей, так как нам необходимо добиться совместимости со старым API. У полей формы появится новое поле attributes — с его помощью будут передаваться дополнительные параметры.

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

Определение формы:

 PlayFormFactory.form[FormData](_.string(_.name)(_.label("Имя").someAttribute(42))

Шаблон с формой:

@(form: com.naumen.scala.forms.play.ExtendedForm[RegistrationFormData])

<div>
	....
	@myComponent(form(_.name))
	....

</div>

Шаблон компонента myComponent:

@(customField: com.naumen.scala.forms.play.ExtendedField)
<div>
	....
	@customField.ext.attrs("someAttribute")
	....
</div>

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

Автор: ygrabovskiy

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js