HTMLフォームを操作するためのScala上のDSL



確かにあなたの多くは、HTMLフォームの作成と処理のプロセスに精通しています。 典型的なWebアプリケーションにとっては些細なことかもしれませんが、企業部門で働いている場合、状況は少し異なります。 クライアント、ドキュメントなどを作成または編集する形式は、日常業務になります。 開発中のJavaフレームワークは、それらを操作するためのより便利なAPIとコンポーネントを提供します。 しかし、それにもかかわらず、多くの人は、フォームの操作をもう少し便利にすることが可能かどうか疑問に思っているでしょう。
もちろん、まず、フレームワークで次のタスクをできる限り簡単にしたいと思います。

さらに、コンパイル段階で多くのエラーが検出されることが望ましい。

この記事では、Scalaで独自のDSLを作成するプロセスを説明し、Play Framework 2のコンテキストでフォームを記述する新しい方法を適用する方法を示します。

用語について少し。

この記事では、内部DSLについて説明しています。 内部DSLは新しい言語ではありませんが、ホストプログラミング言語の構文を使用してドメインを記述する便利な方法にすぎません。 確かに、ホスト言語の構文に十分な柔軟性がある場合、内部DSLはこの領域用に設計された新しい言語のように見える場合があります。 このオプションの利点には、開発環境が内部DSLを理解し、構文を強調し、自動補完オプションを提供するという事実が含まれます。 比較のために、外部DSLは独自のパーサーを必要とする本当に新しい言語です。

この記事で説明されているDSLは、元々Play Framework 2のフォームエンジンの制限のために発生した問題を解決するために作成されました。

DSL開発をどこから始めますか?

まず、結果として何を取得するかを決定します。 「この問題を解決するための理想的なDSLはどうあるべきか」というトピックを空想する必要があります。しばらくの間、ホスト言語の構文によって制限されることを忘れます。
例として、次のフィールドを含む登録フォームを使用します。

それを説明するには、擬似コードでそのようなエントリを作成できます。
form( string(email, required, validate(EmailAddress)) string(name, required) date(birthDate) ) 

次に、それを実装する方法について考えます。 最初に、フォームの一般的な説明、つまり一連のフィールドとそのタイプを検討します。 Scalaは、そのような記述を作成するためのいくつかのアプローチを提供します。

1. Builderパターンとメソッドチェーン

フォームのファクトリメソッドシグネチャ:
 def form(builderFoo: FormBuilder => FormBuilder) 

使用法:
 form(_ .string(...) .string(...) .extend(commonFields) .date(...) ) 

ここでは、 ビルダーメソッドチェーンパターンを使用します。 これにより、次の利点が得られます。

今後、このオプションが選択されたと言います

2.可変数のパラメーターを持つ関数として

工場メソッド:
 def form(fields: FormBuilder => FormBuilder*) 

使用法:
 form( _.string(...), _.string(...), _.date(...), commonFields:_* ) 

このオプションにはいくつかの欠点があります。

3.呼び出しシーケンスを含むコードブロック

工場メソッド:
 def form(fields: => ()) 

 : form{ string(...) date(...) commonFields() } 

この方法は次の点で悪いです:


そのため、予備分析では、よく知られている設計パターンを使用したオプションが最適であることを示しました。 サプライズ

フィールドの説明

フィールドの詳細な説明については、メソッドチェーンとビルダーも同じ理由で選択されています。
フィールド定義メソッドのシグネチャ:
 def string(fieldFoo: FieldBuilder => FieldBuilder): FormBuilder 

使用法:
 .string(_.name("email").required.validate(EmailAddress)) 

ビルド実装のスケッチ

自動的に定義されたcopyメソッドを持つcase classは、Scalaでビルダーを実装するのに最適です。 このメソッドは現在のオブジェクトをコピーし、必要な属性を変更します。 個々のフィールドのビルダーは次のようになります。
 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 = ??? } 

この場合、フィールド識別子を手動で指定する必要があることに注意してください。 同時に、誤った識別子を誤って指定した場合(たとえば、封印したなど)、プログラムの実行中にのみエラーについて学習します。

フォームデータの提示

ユーザーがフォームを送信すると、使用されるWebフレームワークはリクエストデータを受信し、それを独自のフレームワーク固有の表現に変換します。 フレームワークとの統合のタスクの1つは、フォームの内部表現を便利なものに変換することです(このプロセスについては、Playフレームワークを参照して記事の最後で簡単に説明します)。

フォームデータの表示は、 case classインスタンスとして便利です。 簡潔にするために、「フォームデータオブジェクト」または単に「フォームデータ」と呼びます。 フォームフィールドは、このクラスの入力フィールドによって表されます。 この場合、フォームデータオブジェクトの目的のフィールドのゲッターを呼び出すことにより、クロージャーでフォームフィールドを識別できます(たとえば、クロージャー(_.someField)"someField"というフォームフィールドに対応します)。

文字列の代わりにクロージャーを渡すことには重要な利点があります-クロージャーのタイプを知っているため、フォームフィールドのタイプを制御できます。 たとえば、 UserFormData => String型により、フォームフィールドは文字列のみであることを決定できます。

リフレクションを使用してフィールド名を取得する

Scalaでフィールド名を取得するには、少なくとも2つの方法があります。1つ目は、リフレクションを使用する従来の方法、2つ目はマクロを使用する方法です。 リフレクションは、確立されたかなり控えめなメカニズムである反面、マクロは実験段階にある新しい機能であり、その使用にはいくつかの制限があります。 しかし、それらはコンパイル時にフィールドの名前を決定することを可能にします。これはもちろん重要なプラスです。 この記事では、自分自身をリフレクションに制限します(次回はマクロを取り上げます)。
そのため、次の方法でフィールドを定義する機能に関心があります。
 form[FormData](_.string( _.someField )(_.someProperty)) 

つまり _.someFieldことにより、名前"someField"取得します。
これを行う関数には、次のシグネチャがあります。
fieldName[T:Manifest](fieldFoo: T => Any): String
[T:Manifest] という表記は、追加の暗黙的なパラメーターのリストがメソッドシグネチャに追加され、コンパイラーからManifest [T]型の引数が必要であることを意味します。 実際、これはType ErasureのようなJVMでの現象を克服する松葉杖にすぎません。 マニフェストを使用すると、実行時に型パラメーター (JavaのGenericクラスの型変数に類似)を取得できます
マニフェストからクラスを受け取ったら、そこから動的プロキシ proxyObjectを構築します。これは、渡された型を満たすオブジェクトであり、その唯一のタスクは、呼び出された最初のメソッドの名前を報告することです。 クロージャ(_: T).someFieldfieldFoo: T => Anyとして渡される場合、 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 RegistrationFormData( name: String, surname: Option[String], email: String, birthDate: Option[Date] ) 

nameフィールドのゲッターのタイプはRegistrationFormData => Stringで、 surnameゲッターのタイプはRegistrationFormData => Option[String]です。 したがって、フィールドを決定するには2つの方法が必要です。
必要な場合:
(def string(fieldFoo: T => String)(foo: FieldBuilder[String] => FieldBuilder[String])
オプションの場合:
def stringOpt(fieldFoo: T => Option[String])(foo: FieldBuilder[String] => FieldBuilder[String])
フィールドの各タイプ(ブール値を除く)には、両方のオプションが必要です。 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) ... } 


さらに、ここではtypeパラメーターを使用してfieldTypeプロパティを定義します。

DSL拡張

現時点では、 FieldBuilderを使用して、 labelrequiredプロパティのみを指定できますが、これでは不十分です。 開発者は、タスクで必要な場合にフィールドプロパティのセットを拡張できる必要があります。 さらに、異なるタイプのフィールドは異なるプロパティを指定する必要があります。
この問題を解決するために、 暗黙的な変換とPimp My Libraryパターンを使用します。

FieldBuilder次のメソッドを追加します。
def addProperty(key: String, value: Any): FieldBuilder[T]
次に、フィールド構成全体を設定します。 DSLのユーザーは直接DSLにアクセスせず、ユーザーAPIは次の形式の暗黙的なタイプコンバーターを構成します。
 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) } } 

暗黙のクラスはAnyVal継承することに注意してください。 これは、実行時にこのラッパークラスのオブジェクトがインスタンス化されないようにするために必要ですが、代わりに静的メソッドがコンパニオンオブジェクトで呼び出され、コンパイラによって暗黙的に作成されます。

FormBuilderですべてが完璧というわけではありません。その助けを借りて、現時点ではかなり限られたフィールドタイプのセットを追加できます。 最も一般的なタイプをすべて追加することは難しくありませんが、 FormBuilderない新しいタイプのフィールドが必要な場合はどうでしょうか? この問題は、ベースの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)) _ } } 


フォームの内部表現

ビルダーの作業の結果、String- String -> FieldDescriptionを含むFormDescriptionフォームの説明オブジェクトを取得しString -> FieldDescriptionFieldDescriptionに、 FieldDescriptionFieldDescription String -> Any mapが含まれます。 したがって、必要な属性にフィールドを設定できます。 私の意見では、フォームの結果の記述は完全にフレームワークに依存しません。 さまざまなフレームワークで使用できます。 必要なのは、使用されるフレームワークの形式のプレゼンテーションへの変換を実装することだけです。 次に、Play Frameworkでこれがどのように行われたかを見ていきます。

プレイ統合

Playにはすでに、それほど複雑ではないフォームに便利なメカニズムがあります。 ただし、次の制限があります。

フォームを送信すると、PlayはHTTPリクエストを受信し、それをデシリアライズし、リクエストパラメーターをMap[String, Seq[String]]として提示します。 その後、それらは検証され、カスタムコードに適したビューに変換されます。 組み込みのPlayツールを使用すると、このデータをTupleまたは任意のオブジェクトに変換できます。ただし、このためには、作成する機能を提供する必要があります。 フォームに十分なフィールドがある場合、エラーが発生する可能性のあるコードにつながる可能性があります。 想像してください。18個の関数引数が正しい順序であることを確認する必要があります。

生データを検証および変換するために、PlayはMappingクラスを使用します。
Playに組み込まれたフォームのマッピングは、フィールドのマップマッピングを個別のコンストラクター引数として受け取ります(例: ObjectMapping9 )。したがって、フォームは、定義時に指定された厳密に固定されたフィールドセットのみを持つことができます。 FormMappingと呼ぶクラスは、任意の数のフィールドで機能します。 一方、渡されたフィールドは入力を失いますが、 FormMappingフィールドを手動で操作することを意図していないため、これは怖いことでFormMappingません。 フィールドの入力はDSLによって保証されており、フォームデータオブジェクトへの変換、およびその逆は、リフレクションを使用して自動的に行われます。

Playのフォームは、ケースクラス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> 

フォームを決定する段階で任意のフィールドパラメータを指定できるため、テンプレートのレイアウトに集中し、非レイアウトパラメータの定義をコントローラに転送できます。

Source: https://habr.com/ru/post/J206996/


All Articles