2022年2月22日 (火)

Dateをmockするメモ

0. 時刻が絡むとめんどくさい

使い古された内容だけに、多分に自分用メモ。

よくこんなコードを書かないだろうか。僕はとってもよく書く。

  Date date1 = new Date();
  // あるいは
  Date date2 = Date.from(Instant.now());
        

よくある「現在時刻が必要な処理」で出てくるやつである。

プロダクションコードだけを考えるなら、まったくこれで問題はないと思うんだけど、ちゃんと時刻が利用できているかどうかをユニットテストで確認しようとすると、これでは途端に問題になる。

こういう場合Date部分を生成部分を固定の値を返すようなモックに置き換えることができれば、安定してテストが成立しうるので、その方法を調べることに。

1. mockしてやる

ググってみると、Overriding System Time for Testing in Javaという、安定のbaeldungさんの記事がヒット。

要はInstant.now()をモックすることで対応可能とのこと。

「そういやspring-boot-starter-testにはmockito含まれてたよな?」と思い出したので確認してみる。

  $ ./gradlew dependencies
  \--- org.springframework.boot:spring-boot-starter-test -> 2.6.3
  +--- org.mockito:mockito-core:4.0.0
  +--- org.mockito:mockito-junit-jupiter:4.0.0
  |    +--- org.mockito:mockito-core:4.0.0 (*)
        

うむ、間違いなし。PoCプロジェクトにしたSpring Boot 2.6.3では、mockito-core 4.0.0が入っているようだ。

これを利用して、固定の時刻を返すDateを作ってみる。

プロダクションコード
  @NoArgsConstructor
  @AllArgsConstructor
  @Getter
  @Setter
  public class TimeNotification {
      @JsonProperty("current_time")
      @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Tokyo")
      private Date currentTime;
  }

  @RestController
  public class MainController {
      @GetMapping("/")
      public TimeNotification notifyTime() {
          return new TimeNotification(Date.from(Instant.now()));
      }
  }
テストコード
  @Test
  void index() throws Exception {
      // Instant側はZ時刻帯(=UTC)、ZoneInfoはJSTなので、9時間ずらす
      Clock clock = Clock.fixed(Instant.parse("2022-02-22T12:11:23Z"), ZoneId.of("Asia/Tokyo"));
      Instant instant = Instant.now(clock);

      try(MockedStatic mockedInstant = Mockito.mockStatic(Instant.class)) {
          mockedInstant.when(Instant::now).thenReturn(instant);

          mockMvc.perform(get("/"))
                  .andExpect(status().isOk())
                  .andExpect(content().json("{\"current_time\":\"2022-02-22 21:11:23\"}"))
                  .andReturn();
      }
  }

で、テストを実行してみたのだが、

org.mockito.exceptions.base.MockitoException: 
The used MockMaker SubclassByteBuddyMockMaker does not support the creation of static mocks

Mockito's inline mock maker supports static mocks based on the Instrumentation API.
You can simply enable this mock mode, by placing the 'mockito-inline' artifact where you are currently using 'mockito-core'.
Note that Mockito's inline mock maker is not supported on Android.

...と怒られる。スタックトレースを読むと、スタティックメソッドのモッキングにはmockito-inlineというライブラリが必要な様子。

build.gradleに追加。

  dependencies {
      testImplementation "org.mockito:mockito-inline:4.0.0"
  }

これでようやくテストが通るようになった。

2. 本日のソースコード

全文はこちらをどうぞ。

2021年6月28日 (月)

Spring Bootを最新化した

0. 重い腰を上げてアップグレード

弊社、Yahooショッピングの受注処理を自動化しているんだが、Spring Bootのバージョンが2.2.0.RELEASEなのでそろそろバージョンアップせねば、ということで作業開始。

1. 2.2から2.3へ

2.2から2.3へは、Spring Boot 2.3 Release Notesを見ながら作業するわけだが、弊社の環境で必要な作業は

だった。

Gradle 6.3以降への更新については、wrapperタスクを実行するだけなので簡単。

$ ./gradlew wrapper --gradle-version 6.3

次にspring-boot-starter-validationの依存追加は、spring-boot-starter-webから依存が外れるため。[出典]
ということなので依存をbuild.gradleに加える。

implementation("org.springframework.boot:spring-boot-starter-validation")

設定パラメータの移行については、spring-boot-properties-migratorというヘルパーがあるので、そいつをいったん依存に追加して起動することで、移行先を教えてくれる。
移行が終了したら依存から削除しておくこと。

runtime("org.springframework.boot:spring-boot-properties-migrator")

ここまでできたら、build.gradleのpluginブロックに書かれているバージョンを2.3に書き換えれば、Spring Boot 2.3になる。

以前1.4から2.0に移行したときのことを考えると、ずいぶん楽になった印象。

やってる最中で、今まで通っていたテストが通らなくなるという事案があったので、テストを修正。
そして、テストが通ったところでひとまず本番にデプロイ。

2. 2.3から2.4へ

ここからが本題。詳しくは2.4のリリースノート全文をご覧いただくとして、2.3から2.4では設定ファイルに大幅な修正が入っている。

Springにはプロファイル機能があり、テスト用、本番用と環境を切り替えることが容易になっているのは周知の事実だが、外部からのプロパティ指定や内部のデフォルト値上書きなどで時折(Spring Bootの)開発者が意図しない順序でプロパティが展開されるケースがあった、とのことで、2.4でそのあたりを大々的に改修したのだそう。出典をメモることができなかったが、「よくないのはわかっていたが、奇跡的にうまく動いていたので改修しようとしてもいろいろ反対があった」そうな。

ひとまず今すぐに移行できないような場合は、

spring.config.use-legacy-processing=true

を追加することで、今までと同じ挙動になる。のだが、ゆくゆくは削除されることは明白なので、新しい方式に作り直すことにした。

  spring:
    profiles:
      active: develop1
  settings:
    param_a: "param_common"

  ---
  spring:
    profiles: develop1
  settings:
    param_b: "param_devel1"

  ---
  spring:
    profiles: develop2
  settings:
    param_b: "param_devel2"

みたいなapplication.ymlがあったとして、これをspring.config.use-legacy-processing=trueなしで動かそうとすると、settings.param_bの値は常にparam_devel2になってしまう。

PoCプロジェクトを作って試してみたところ、

  • 共通部分としていたデフォルト値の集合部分を、独立したプロファイルの断片に切り出す
  • プロファイル固有部分を、上記同様独立したプロファイルの断片として切り出す
  • 従来プロファイルとしていた部分をプロファイルグループ名にし、必要なプロファイルの断片をその中に入れていく

という作業が必要だった。
これを踏まえ先ほどのapplication.ymlを書き換えたのがこちら。

  spring:
    profiles:
      active: develop1
      group:
        develop1: "devel1,common"
        develop2: "devel2,common"

  ---
  spring:
    config:
      activate:
        on-profile: devel1
  settings:
    param_b: "param_devel1"

  ---
  spring:
    config:
      activate:
        on-profile: devel2
  settings:
    param_b: "param_devel2"

  ---
  spring:
    config:
      activate:
        on-profile: common
  settings:
    param_a: "param_common"

ひとまずこれができたところで、2.3の時と同様build.gradleのpluginブロックに書かれているバージョンを2.4に書き換えれば、Spring Boot 2.4になる。

logback-spring.xmlで行っていた設定もapplication.ymlでできるようになっているけど、今回は割愛。

CIも通ったので、その足で本番デプロイ。

3. 2.4から2.5へ

こちらも難儀した。このバージョンではデータソースの初期化の順序とプロセスが整理されたのだが、恥ずかしながら自分が書いたとはいえ弊社のデータソース初期化まわりはこんな感じになっていた。

  • バッチ -> 原則事前定義済みスキーマを使用、テストで使うインメモリH2の場合のみschema.sqlで初期化
  • Web -> すでにスキーマがあることを前提とした内容のFlyWay管理

How To guideのInitialize a Database Using Basic SQL Scriptsによれば、schema.sqlとFlyWayのような高度なスキーマ管理機構を併用するのは推奨されなくなった。将来的に削除する方針でもあるそうなので、言い換えれば混ぜるな危険である。

ということなので、Webプロジェクトで使っていたFlyWayのバージョンマイグレーションの前に、既存のschema.sqldata.sqlが実行されるようsrc/test/resources/db/migration以下にマイグレーションファイルを作成することで対応した。
幸い、本番のマイグレーションファイルのバージョン表記は1からの連番ではなく日時のシリアル値を使用していたおかげでこういうことができたのだが、バージョンの開始を1にしていたら大変なことになっていただろう。

また、テスト時は常にマイグレーションがかかるよう、

spring.flyway.baseline-on-migrate=false

を設定した。

一方、バッチにもその実行結果とフェーズを記録するテーブル群があるので、その初期化用設定をバッチのプロジェクトに入れていく。

  spring:
    batch:
      jdbc:
        initialize-schema: "embedded"

Spring Bootのチーム曰く、「今まで奇跡的に動いていた」そうで(またかよw)、今回そこに手を入れたことで今まで通っていたテストがフィクスチャー不足で動かなくなったので、不足分を補うようテストを修正した。

最後に、Gradleが6.8以上必須になったので、wrapperタスクでGradleのバージョンを上げるわけだが、新規にSpring Boot 2.5.1のプロジェクトを作るとGradle 7.0.3を使っていたので、せっかくなので現時点の最新安定版の7.1を指定した。

ここまでできたら、build.gradleのpluginブロックに書かれているバージョンを2.5.1に書き換えれば、Spring Boot 2.5.1になる。

CIも通ったので、その足で本番デプロイ。

4. 最後に

application.ymlの移行が個人的に一番難しかったが、わかってしまえば設定を意味のある部品単位にできるので、むしろ整備性と部品の再利用がやりやすくなった、と思う。

2020年10月10日 (土)

Webショップ運営を(ほぼ)自動化した件

0. 弊社、Web販売もやってます

弊社はYahoo!ショッピングに出店しているんだが、問屋とのやり取り含め、その運営がまるごと手作業で結構手間だったりする。

一部の問屋は発送の代行もしてくれる、いわゆるドロップシッピング業者なのだが、そこを使った場合でも大まかな流れはこんな感じ。

1

人が監視する手前、受注はYahoo!ショッピングから送られてくるメールがトリガーになるので、担当者が不在の場合はどうしても遅れが生じるし、問屋への発注締め切り時刻を超過してしまうとその分エンドユーザーへの発送も遅延する。というのが悩みの種だった。

1. なので自動化に挑んだ

幸いYahoo!ショッピングにはストア運営を自動化するためのAPIが用意されているので、これを利用して受発注と発送通知の自動化に挑んだ。

で、こんな感じに。

2

1-1. 受注をfetchして発送依頼データを作るまで

件のストア運営を自動化するためのAPIを使ってストア運営する流れについては、公式の注文APIガイドに沿って順番にAPIコールしていけばいいので、これ自体は特に難しいことはなかった。
ただ、ここのAPIはXMLを投げ合う類だったりする。

肝心のシステムにはSpring Batchを使った。言語はJava。理由は単純、自分がJavaに比較的慣れていたためだ。

幸いJavaにはJava Architecture for XML Binding(JAXB)という公式の自動マッピング仕様があるため、XMLによるリクエスト/レスポンスデータとオブジェクトとの相互変換には、これを使うことで比較的容易に対応できた。
とはいえ、最初は「XMLかよ~」と泣きながらJAXBの解説記事を漁っていたのだが(あせ

で、取得した受注データから、発送依頼用ファイルを作るわけだが、相手方がシフトJISなCSVでのみ受け付けるため、色々見て回った結果opencsvを使うことにした。オブジェクトからCSVを生成するだけなので、特段苦労はなかった。

1-2. 発送依頼データをアップロードする

相手方の卸業者は受注や発送状況に関するAPIを持っていないとの話(こればっかりは仕方ない)なんだが、先方に確認したところ先方に確認したところ幸いにもRPA等の機械的なアクセスまでは禁じているわけではなかったので、Seleniumによるオートパイロットで発送依頼処理を代行させることにした。

こちらも最初はすべてSpring Batchで書いていたんだが、以下のような問題が出てきたのでSpringで書くのをやめ、処理を分離することにした。

  • WebDriverをSpringでBeanにしている場合、個々のオートパイロット処理の終了時にWebDriverをクローズすると、その「クローズされた状態」を次の処理で使いまわすため、WebDriverが開始できなくて倒れる
    まぁ、Seleniumはもともとテストツールで、JUnitなんかと一緒に動かすことが前提のものであって、インスタンスが使いまわされるような使用法は想定していないんだろう
  • AWSに受発注処理用サーバを置いて動かしていたら、相手方から攻撃とみなされてしまったらしく、IPベースで接続を遮断されてしまったため、AWS上に置けなくなってしまった
    「パブリックIP変えればええやん」とお思いになるかと思うが、Yahoo!ショッピングの本番系にAPIアクセスするには固定のパブリックPが必要で、これを変えるにはまた申請が必要だったりするので面倒

後半のやつについては「やりすぎました、ほんとごめんなさい」というだけの話で、先方にも当然のことながら謝りを入れた。けれども、一度遮断したIPアドレスを開けてくれるわけもなく、引っ越しを余儀なくされることに。

どういうわけか弊社には、社内システム用のプライベートクラウド的な仮想化基盤があり、まだ余裕がありそうだったのでそこに発送依頼データのアップロードを行うVMインスタンスを作ることにした。

引っ越し先は決まったとして、Spring Batchで書いていた処理だけを切り出してやるのではなく、せっかくなのでGoで一から書いてみることにした。
要件的にDBアクセスが必要な個所はなかったことと、幸いにもほぼSeleniumなagoutiという受け入れテスト用ライブラリがあったので、agoutiでオートパイロットする処理を実装。AWS側で発送依頼データのCSVを作ったらscpでアップロード用VMに送り込み、頃合いを見計らってGoで作ったオートパイロット処理でファイルをアップロードする、という算段だ。

1-3. 発送状況の追跡と更新

こちらは、前述とは逆の流れ。
先方からは発送状況をCSVでダウンロードできるので、Goで作った「発送状況CSVをダウンロードする処理」でCSVをダウンロードし、こいつをscpでAWS上の受発注処理用サーバに送り込んで、Spring BatchでCSVのパースと発送ステータスの変更を行う、という流れになる。

この段階でも、CSVがシフトJISという以外は特段苦労なくパースできた。

Yahoo!ショッピングのAPIで該当の注文に対してパース結果の運送業者と配送伝票番号を埋めたあと、Springのメール送信機能で注文者に発送状況を通知している。本文については、Thymeleafのテキストテンプレート機能を使った。

Springのメール送信機能については、Guide to Spring Emailという記事が大いに参考になった。

2. で、どうなったか

ひとまず、Webでの受発注はひととおり自動化したのだが、そうもいかない場合も存在する。例えばこんな感じ。

  • 「領収書の要否をカートに表示する」設定にしていて、注文ボタンが出ている画面に「領収書をどうするか」という選択が出ているのに、領収書不要でお客様要望欄に「領収書をください」と書いている場合がある
  • 同じく、領収書必要にはなっているが、お客様要望欄に宛名や但書に関する指定がある
  • 期日指定配送は承っていないのだが、お客様要望欄に「○○月××日必着で願います」と書かれている場合がある

こういう場合はどうしても人による確認が必要になるので、どんな対応が必要なのかを社内のMLに流す処理を追加することで対応した。一応、人間様は結果だけ受け取るという状況にはできたといえるが、今後は

  • 領収書の自動作成(※額面5万円以上の場合は印紙が必要になるのでテンプレートはそこで分ける必要があるが)
  • 受注結果をチャットのタイムラインに流す

あたりをやっていきたいと思う。

2019年9月16日 (月)

Apache Wicketを使ってみる[4] -認証/認可編-

0. Wicketで認証/認可を扱うには

wicket-auth-roles というdependencyが必要。

<dependency>
  <groupId>org.apache.wicket</groupId>
  <artifactId>wicket-auth-roles</artifactId>
  <version>8.5.0</version>
</dependency>

1. Wicketでの認証

通常の WebApplication に認証情報を処理できるような拡張が施された AuthenticatedWebApplication と、認証処理と認証情報のセッションストアへの格納を行う AuthenticatedWebSession で構成される。

二つとも abstract なので、使うには継承したうえで実装しなければならない処理がある。

AuthenticatedWebApplication AuthenticatedWebSession
  • protected Class<? extends WebPage> getSignInPageClass()
    サインインページを構成するクラスを返す
  • protected boolean authenticate(String username, String password)
    ユーザー名とパスワードから認証を行う
  • protected Class<? extends AbstractAuthenticatedWebSession> getWebSessionClass()
    アプリケーションで使うセッションストアのクラスを返す

上記のユーザーガイドからの抜粋をKotlinで書いてみると、こんな感じになる。

  • セッションストアクラス
class BasicAuthenticationSession(request: Request) : AuthenticatedWebSession(request) {
  override fun authenticate(username: String, password: String) : Boolean {
    // ここで認証処理を行う
    // 通常であれば、DBアクセスしてユーザーの有無やパスワード比較とかをする、はず
    return username == password && username == "wicketer"
  }

  override fun getRoles() : Roles {
    // ロールベースのアクセス制御用に、ログイン中ユーザーのロールを返す
    // 通常であれば、セッションストアのユーザー情報からロールを引いたりする、はず
    return Roles()
  }
}
  • アプリケーションクラス
class WicketApplication() : AuthenticatedWebApplication() {
  override fun getHomePage() : Class<out WebPage> {
    // いわゆる「スタートページ」を構成するクラスを返す
    return HomePage::class.java
  }
  override fun getWebSessionClass() : Class<out AbstractAuthenticatedWebSession> {
    // アプリケーションでつかうセッションストアを返す
    return BasicAuthenticationSession::class.java
  }
  override fun getSignInPageClass() : Class<out WebPage> {
    // いわゆる「ログインページ」を構成するクラスを返す
    return SignInPage::class.java
  }
}
  • サインインページ
class SignInPage() : WebPage() {
  private var username: String = ""
  private var password: String = ""

  override fun onInitialize() {
    super.onInitialize()

    // ログインフォームを構成するStatelessForm
    val form: StatelessForm<Unit> = object : StatelessForm<Unit>("form") {
      override fun onSubmit() {
        if (username.length == 0) {
          return
        }

        // 認証処理を呼び出し、認証成功のときは前ページで設定されたリダイレクト先へ遷移する
        val authResult = AuthenticatedWebSession.get().signIn(username, password)
        if (authResult) {
          // ここではそのままリダイレクトしているが、ここでユーザーロールのチェックとかをはさむ、はず
          continueToOriginalDestination()
        }
      }
    }

    // ModelとTextFieldをformに追加する
    form.model = CompoundPropertyModel(this)
    form.add(TextField<String>("username"))
    form.add(PasswordTextField("password"))

    add.(form)
  }
}

サインインページのデフォルト実装はあるけれど、同じくデフォルト実装のパネルと組み合わせて使う想定のようで、自力で書くのと大差ないかも....

2. 途中で認証をはさみたいときは

「トップページとかは普通に見れるようにするが、あるページは会員だけに見せたい」といった場合、途中でユーザー認証を行い、認証に成功したらユーザーがもといたページに戻す、といった処理をやることになる。

そういった場合は、以下の二つの方法をとる、とのこと。[出典]

いずれも、実行時に遷移前の状態をセッションストアに保存したあと、認証に成功した場合認証前の状態に復元してくれるが、認証ページへの遷移直前に RestartResponseAtInterceptPageException が内部的にスローされてページ遷移するので、以後の処理は行われなくなる。

3. Wicketでの認可

いわゆる処理に対する権限チェックである。先ほどの例で出てきた AuthenticatedWebSession#getRoles() も、認可に関する処理の一つ。
「ログインしているか否か」も、認可の範囲に入る模様。

基本処理は IAuthorizationStrategy で規定されている

boolean isActionAuthorized(Component, Action) アクションが許可されているかどうかを返す。
boolean isInstantiationAuthorized(Class<T extends IRequestableComponent>) 指定されたコンポーネントをインスタンス化できるかどうかを返す。
boolean isResourceAuthorized(IResource, PageParameters) リクエストのパラメータが、リソースへのアクセスが許可されているかどうかを返す。

だが、すべてを許可するALLOW_ALLという実装のほか、ページ単位のチェックを行う規定処理がすでに組み込まれている。個人的には、一番しっくりきたのがロールベース戦略として紹介されている MetaDataRoleAuthorizationStrategyAnnotationsRoleAuthorizationStrategy だった。
それぞれ、用途的には以下のような違いがある。

MetaDataRoleAuthorizationStrategy アプリケーション起動時に、あらかじめ使用可能な権限をページごとに列挙しておく
AnnotationsRoleAuthorizationStrategy ページクラスに対し、アノテーションで権限を宣言していく

両者とも一長一短ではあるけど、たとえば、 AdminOperatablePage というページに「サインインしていたら表示可能、スーパーユーザーなら中のFormを操作可能」という権限を設定する場合、

  • MetaDataRoleAuthorizationStrategy のばあい
class WicketApplication() : AuthenticatedWebApplication() {
  override fun init() {
    securitySettings.authorizationStrategy = MetaDataRoleAuthorizationStrategy(this)

    // ログインしていたら "AdminOperatablePage" はインスタンス化を許可
    MetaDataRoleAuthorizationStrategy.authorize(AdminOperatablePage::class.java, "SIGNED_IN")

    // adminForm というコンポーネントは、"ADMIN"なら操作可能にする
    // "ADMIN"でないときはロックされる
    MetaDataRoleAuthorizationStrategy.authorize(adminForm, Action.ENABLE, "ADMIN")
  }
}
  • AnnotationsRoleAuthorizationStrategy のばあい
@AuthorizeInstantiation("SIGNED_IN")
@AuthorizeAction(action = "ENABLE", roles = {"ADMIN"})
class AdminOperatablePage(params: PageParameters) : WebPage(params) {
  // @AuthorizeInstantiation で "SIGNED_IN" を持つ場合にインスタンス化を許可
  // ここの @AuthorizeAction は、ページ全体に効果がある
  ...
}

....といった感じになるはず。

なお、権限は AuthenticatedWebSession#getRoles() で取得されるので、先ほどのセッションストアクラスを書き直してみると、

class BasicAuthenticationSession(request: Request) : AuthenticatedWebSession(request) {

  override fun getRoles() : Roles {
    // ロールは中身がHashSet<String>なので、条件に応じたロール識別文字列をaddしていく
    // @see https://ci.apache.org/projects/wicket/apidocs/8.x/org/apache/wicket/authroles/authorization/strategies/role/Roles.html
    val resultRoles = Roles()

    if (isSignedIn()) {
      // たとえば、サインインしていたら"SIGNED_IN"をおく、とか
      resultRoles.add("SIGNED_IN")
    }
    if (username == "superuser") {
      // スーパーユーザーだったら"ADMIN"を置く、とか
      resultRoles.add("ADMIN")
    }
    // このほか、ユーザーのオブジェクトを保管しているなら、そのオブジェクトにぶら下がっている
    // ロールをaddしていくことになる、はず

    return resultRoles
  }
}

...といった具合になるはず。

4. 認可に失敗したときは

認可に失敗したとき、デフォルトでは

  • ユーザーがサインインしていないときは、既定のサインインページへ遷移させる
  • サインインしているがコンポーネントが認可できない場合は、 UnauthorizedInstantiationException がスローされる

....という挙動だが、カスタム処理にしたい場合は IUnauthorizedComponentInstantiationListener というイベントリスナがあるので、これの中で処理する。

  securitySettings.unauthorizedComponentInstantiationListener = IUnauthorizedComponentInstantiationListener {
    // 認可失敗を処理する "AuthWarningPage" というページをレスポンスにする
    it.setResponsePage(AuthWarningPage::class.java)
  }

ロールの検証を追加するなら、こんな感じになるはず。

  securitySettings.unauthorizedComponentInstantiationListener = object : IRoleCheckingStrategy, IUnauthorizedComponentInstantiationListener {
    override fun onUnauthorizedInstantiation(component: Component?) {
      // 認可失敗を処理する "AuthWarningPage" というページをレスポンスにする
      component!!.setResponsePage(AuthWarningPage::class.java)
    }

    override fun hasAnyRole(roles: Roles): Boolean {
      // 指定ロールが見つかったらtrueを返す処理を書く
      var result = roles.hasRole("ADMIN")
      return result
    }
  }

まぁ、このあたりはデフォルト実装でもどうにかなるレベルなので、やるかどうかはケースバイケースか。

2019年9月 7日 (土)

Apache Wicketを使ってみる[3] -Model編-

0. Wicketでいう「Model」とは何か

Modelというと、だいたいビジネスロジック層とかデータアクセス層とかの認識でいるわけだが、Wicketにおける「Model」はセッションストアを含むデータアクセスも、Viewへの入出力を含む状態の受け渡しもやる。[出典]
こう書いてしまうと、かなりViewModelっぽい気もする。

1. 基本中の基本

単純なラベル表示の場合、たいていのチュートリアルにはこう書かれているはず。

HTML
<div><span wicket:id="strLabel">Label!!</span></div>
Java
add(new Label("strLabel", "Hello, World!!));

要は「"strLabel"というIDをもつ要素に"Hello, World!!"という文字列を置け」というだけのものだけれど、内部的には

add(new Label("strLabel", new Model<String>("Hello, World!!")));

と、Viewへの出力を行う基本的な実装を持つModelを仲介している、そうな。[出典]

2. 応用編

前述の例は単純なリテラルの出力だけだけど、当然のことながら「Java Beansとのやりとり」も「別のModelが吐いた出力を受けた入力」も、Modelとして処理できる。

全文はhttps://github.com/bitstorm/Wicket-tutorial-examples/tree/master/ModelChainingExampleを参照いただくとして、「ドロップダウンリストの選択値変化に応じてFormのテキストボックスの値を動的に変更する」処理も容易に実現できる。
なお、こちらのコードは例によってKotlinで書いてみた。

class PersonListDetails() : WebPage() {
    /** 連動して値を反映させる先のForm */
    private lateinit var forms: Form<Unit>
    /** ドロップダウンリストの中身(select + option) */
    private lateinit var personList: DropDownChoice<Person%>

    init {
        // selectとformの橋渡しをするModel
        val listModel = Model<Person>
        // selectで選択された値を拾うレンダラー
        personRender = ChoiceRenderer<Person>("fullName")

        // ドロップダウンリストを画面へ割り当てる
        personList = DropDownChoice<Person>("persons", listModel, loadPersons(), personRender)
        // 更新通知を受け取るためのイベントリスナー的なもの
        // これをトリガーに、下で定義しているformにModelが値を反映してくれる
        personsList.add(FormComponentUpdatingBehavior());
        // 画面へコンポーネントを反映
        add(personList)

        // selectの変化を受け取るModelを引数にとるModelをformに張る
        form = Form("form". CompoundPropertyModel<Person>(listModel))
        //   それぞれ、listModelが持っているJava Beansのプロパティに紐づいている
        form.add(TextField("name"));
        form.add(TextField("surname"));
        form.add(TextField("address"));
        form.add(TextField("email"));

        add(form)
    }

    // selectに張る中身のListを作る処理
    fun loadPerson(): List<Person> {...}
}

// 名(name)と姓(surname)を受け取る
data class Person(var name:String, var surname: String): Serializable {
    // 住所
    var address: String
    // メアド
    var email: String
    // 配偶者
    var spouse: Person?
    // 子供
    var children: MutableList<Person>?
}

これをStrutsでやろうと思うと、JSPにhiddenでいっぱい値をねじ込んでおかないとサクッとはできないよな....

ちなみに、前述のチュートリアルを動かすには pom.xmlのパッケージング設定がjarになっているのでwarに変更する 必要があるので注意。

2019年8月15日 (木)

Apache Wicketを使ってみる

0. 事の始まり

ある筋から「Apache Wicketを学習しといて」とのお達しがきたので自分向けメモ。

1. Apache Wicketとは

公式の記載をざっくり集約するとこんなところ。

  • 2000年代半ばのJava Webアプリケーションフレームワーク戦争の、数少ない生き残りの一つ(2004年に世に出た)
  • Apacheソフトウェア財団のもと、Apache 2.0 Licenseで公開されているOSS
  • Ajax対応機能を内包
  • 多言語対応(デフォルトで25言語を内包)
  • ページやコンポーネントはJavaオブジェクトとして扱える
  • 複数タブ/ウィンドウへの対応
  • ページやコンポーネントのテストのサポート
  • CDIやSpring、GuiceなどのDIをサポート
  • JPA、Bean ValidationなどのJava EE 6サポート
  • 最新安定版のWicket 8では、Java SE 8の言語仕様に対応するため、Java 8とServlet API 3.1が必須に

まぁ、最後のやつは、いまどきならよほどのことがない限りクリアできていると思うけど。
※そのよほどの(ry

2. セットアップ

プロジェクト自体はMavenで管理可能。
※言うまでもないが、Maven自体の扱いについては割愛。

MavenのコマンドラインはQuick Start Wizardで作成できる。

このページで生成されたコマンドラインをターミナルに投入すれば、Mavenが必要なものをそろえてくれるんだが、Windows使いで、PowerShellを使うときには注意が必要。

PS > mvn archetype:generate `
>>   "-DarchetypeGroupId=org.apache.wicket" `
>>   "-DarchetypeArtifactId=wicket-archetype-quickstart" `
>>   "-DarchetypeVersion=8.5.0" `
>>   "-DgroupId=com.mycompany" `
>>   "-DartifactId=myproject" `
>>   "-DarchetypeRepository=https://repository.apache.org/" `
>>   "-DinteractiveMode=false"

といった具合にmvnの -D オプションをダブルクォーテーションで囲わないと、「出力ディレクトリがわからない」とMavenに怒られる。
ハイフンで始まる部分がPowerShellのオプションと解釈されてしまうのが、どうも原因なようだ。なお、DOS窓なら問題なし。

Mavenがプロジェクトを生成したら、mvn clean installで必要ライブラリをダウンロードする。このあたりは、ほかのMavenプロジェクトと同じ扱い。

あとはIDEにMavenプロジェクトとしてインポートすれば、開発体制はとりあえず整う。
必要があれば、愛用のIDEにプラグインを入れてやろう。

3. 開発サーバの起動

生成されたpom.xmlをみると、dependencyブロックにJetty 9が含まれているので、

PS > mvn jetty:run

で localhost:8080 でJettyが起動する。

4. ページの構成など

Strutsなんかに代表されるクラシックなJava Webアプリケーションと違い、ViewとなるHTMLと、処理を受け持つJavaクラス(≒バッキングビーン)が1対1対応していて、ともに同じJavaパッケージ階層に置く必要がある。

このあたり、どちらかといえばASP.NETに近いかも。ただし、CSSなどを駆使して描画する枠線などは、独自タグのあるASP.NETと違い、普通にCSSで定義する。以下、GitHubにあるexampleから引用。

<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
<head>
  <title>Wicket Examples - sample panel</title>
</head>
<body>
Everything outside of the <wicket:border> tags will be ignored.
Might be handy as preview code.

  <wicket:border>
    <div style="border: 2px dotted #fc0; width: 400px; padding: 5px;">
      before the border contents <br />
    <wicket:body/>
    <br />after the border contents <br />
    </div>
  </wicket:border>

</body>
</html>

JSPとは違い、HTMLにカスタムタグを埋め込む形式なので、普通にプレビューできるのはいいと思う。

サブミットの処理については、バッキングビーンにViewのformに対応するインナークラスを定義して処理するそうな。
以下、こちらもGitHubにある公式のexampleから引用。

public final class GuestBook extends WicketExamplePage {
  /** A global list of all comments from all users across all sessions */
  private static final List<Comment> commentList = new ArrayList<>();

  /**
   * Constructor that is invoked when page is invoked without a session.
   */
  public GuestBook() {
    // Add comment form
    add(new CommentForm("commentForm"));

    // Add commentListView of existing comments
    add(new PropertyListView<>("comments", commentList) {
      @Override
      public void populateItem(final ListItem listItem) {
        listItem.add(new Label("date"));
        listItem.add(new MultiLineLabel("text"));
      }
    }).setVersioned(false);
  }

  /**
   * A form that allows a user to add a comment.
   *
   * @author Jonathan Locke
   */
  public final class CommentForm extends Form<ValueMap> {
    /**
     * Constructor
     *
     * @param id The name of this component
     */
    public CommentForm(final String id) {
      // Construct form with no validation listener
      super(id, new CompoundPropertyModel<>(new ValueMap()));

      // this is just to make the unit test happy
      setMarkupId("commentForm");

      // Add text entry widget
      add(new TextArea<>("text").setType(String.class));

      // Add simple automated spam prevention measure.
      add(new TextField<>("comment").setType(String.class));
    }

    /**
     * Show the resulting valid edit
     */
    @Override
    public final void onSubmit() {
      ValueMap values = getModelObject();

      // check if the honey pot is filled
      final String _comment = (String) values.get("comment");
      if (_comment != null && !_comment.isBlank()) {
        error("Caught a spammer!!!");
        return;
      }
      // Construct a copy of the edited comment
      Comment comment = new Comment();

      // Set date of comment to add
      comment.setDate(new Date());
      comment.setText((String)values.get("text"));
      commentList.add(0, comment);

      // Clear out the text component
      values.put("text", "");
    }
  }

  /**
   * Clears the comments.
   */
  public static void clear() {
    commentList.clear();
  }
}

5. Ajax対応について

サブミット処理の応用。処理を受け付けるクラスの中で、AjaxFallbackLinkで発動用のリンクと紐づけ、AjaxRequestTargetに処理結果を渡す。

このしくみだと、JavaScriptでイベントハンドラをゴリゴリ書かなくてもAjax対応できるのはいいかもしれない。

6. データアクセス

Wicket自体にはデータアクセス用のコンポーネントはないので、Spring Dataなんかと組み合わせる必要がある。

7. 雑感

今回やったところとしてはこんなところ。

  • Viewがあるページには、普通にHTMLのプレビューがきくので、画面の作成はJSPを使うものより効率よくできそう
  • Ajaxをつかった動きのあるページの作成は、比較的簡単に作れそう

認証とテストについては、また日を改めて挑むことにしよう。

2017年9月17日 (日)

ログイン失敗時のメッセージを増やしたい on Spring Boot

0. ことのはじまり

先日、ログイン/ログアウト前後でクエリパラメータを保つ on Spring Bootと題して、ログイン/ログアウト時にクエリパラメータを保持する方法について書いたんだけど、こいつの参照実装を後輩君に教えてやったところ、「ログイン失敗時のメッセージが出なくなったんすけど?」とかいうリプライが。

どうやら、槙 俊明さんのはじめての Spring Boot[改訂版]をもとに(というかほぼそのまんまの模様)Spring Securityの設定をしていたようだが、「ログインに失敗したときに、画面上どうふるまうか」を理解せぬまま、僕が示した参照実装のコードを丸写ししていたようで、あんなリプライを出してきた模様。

このあたり、「自分で考えろ」と突き放してもよかったんだが、後学のために「どうすればいいか」を少々考えてみることにする。

1. 実際どうなっているのか

氏の書籍には、だいたいこんな感じでエラーメッセージ周りの処理が紹介されている。

Viewのhtml(抜粋)



<div th:if="${param.error}>ユーザー名、またはパスワードが違います。</div>

SecurityConfig(抜粋)



protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginProcessingUrl("/login")
.loginPage("/loginForm")
.failureUrl("/loginForm?error")
.defaultSuccessUrl("/", true)
.usernameParameter("username")
.passwordParameter("password")
.permitAll();
}

勘のいい方はお分かりと思うが、


  • 認証に成功したら、「/」にリダイレクト

  • 認証に失敗したら、「/loginForm?error」に移動

と、 URLだけで制御している ことがわかると思う。

それが証拠に、認証に失敗しなくても、「/loginForm?error」をブラウザのアドレスバーに直接入力すると、エラーメッセージが出るはずである。

これ自体は、話を簡単にするための処置なのだろう、と考えているのだが、今回の一件では、認証の成功、失敗で適切にクエリパラメータを加工して流す考慮が必要になるので、アプローチとしては以下の2つがあると考える。


  1. 認証ハンドラで、クエリパラメータを適切に追加、削除する

  2. 失敗時の認証ハンドラで、エラーメッセージをセッションに置く


2. URLだけで制御する場合

Viewの実装をそのままにしたい場合、いままでどおりURLでコントロールするよう、クエリパラメータを適切に追加、削除する処理を書けばよい、ということになる。

たとえば、こんな感じ。

認証成功時(抜粋)



// add whole query parameters to url
String queryParams = request.getQueryString() == null ? "" : "?" + request.getQueryString();

// remove error parameter if present.
if (queryParams.contains("error&")) {
queryParams.replaceAll("error&", "");
} else if (queryParams.contains("&error")) {
queryParams.replaceAll("&error", "");
}

認証失敗時(抜粋)



// add whole query parameters to url
String queryParams = request.getQueryString() == null ? "" : "?" + request.getQueryString();

// add error parameter if not present
if (!queryParams.contains("error")) {
if (queryParams.length() == 0) {
queryParams = queryParams + "?error";
} else {
queryParams = queryParams + "&error";
}
}

汚い実装で恐縮だが、雰囲気は伝わっただろうか。

3. セッションを使う場合

認証失敗時に呼ばれる AuthenticationFailurehandler#onAuthenticationFailure(HttpServletRequest, HttpServletResponse, AuthenticationException) では、認証失敗に至った理由が AuthenticationException のオブジェクトとして受け取ることができるようになっている。(参考

「パスワードが違う」「アカウントが有効期限切れ」「アカウント凍結中」など、詳細に調べることができるので、これを使って流すエラーメッセージを選別すればいい。

たとえば、こんな感じ。

認証失敗時(抜粋)



// Analyze the cause of the error
String errReason = null;
if (exception instanceof BadCredentialsException) {
errReason = "Invalid user name or password.";
} else if (exception instanceof AccountExpiredException) {
errReason = "This account is expired. Please contact administrator.";
} else if (exception instanceof CredentialsExpiredException) {
errReason = "Your password is expired. Please contact administrator.";
} else if (exception instanceof DisabledException) {
errReason = "Your password is disabled. Please contact administrator.";
} else if (exception instanceof LockedException) {
errReason = "Your accouunt is locked. Please contact administrator.";
} else {
errReason = "Unknown problem occured. Please contact administrator.";
}

if (errReason != null && errReason.length() > 0) {
HttpSession session = request.getSession();
session.setAttribute("errReason", errReason);
}

一方Thymeleafには、セッションオブジェクトを扱うための「session」というそのものずばりな予約変数がある。

利用方法は「Spring MVC and Thymeleaf: how to access data from templates - Thymeleaf」という公式ドキュメントに詳しく書かれているので、ひとまずそちらを参照していただくとして、セッションにエラーメッセージを置くなら、セッションから値を取り出して張る処理をViewテンプレートに書く。

たとえば、こんな感じ。

Viewのテンプレート(抜粋)



<div th:unless="${session == null}" th:text="${session.errReason}">Hoge</div>

この方法のメリットは、クエリパラメータをこねくり回す必要がなくなる点で、おそらくこういう方法のほうがフレームワークの設計思想に沿ったものなんじゃないだろうか、と個人的に考えている。

4. とりあえず動く参照実装

URLだけでコントロールしたい場合こちらを、セッションを使う場合こちらを、それぞれご参照ください。

2017年9月10日 (日)

ログイン/ログアウト前後でクエリパラメータを保つ on Spring Boot

0. ことのはじまり

僕は現在、都合により外に出されているんだけど、先日22:00くらいに自宅で自社のグループウェアを開いてみると、弊社内にいる後輩君から「助けてくださ~い(´;ω;`)」とかいうメールが。

いわく、「Spring Bootを使っているアプリがあり、認証にSpring Securityを使っているが、ログイン/ログアウトの際にクエリパラメータ(※ページのアドレスの後ろについている「?」以降のアレ)を保つ必要があり、どうやったらいいのか自分ではわからない」とのことらしい。

まぁ、自分で調べる際の検索キーワードの選定がまずいだけ、のような気もしないでもないのだが、とりあえず参照実装作成までを目標にして、どうやったらいいかを調べてみた。

ということなので、どういう想定でいくかの整理をしてみる。

  • Spring Bootの最新安定版(現時点では1.5.6.RELEASE)を使用
  • 依存ライブラリは、Thymeleaf、Spring Securityと、JS処理用にjQuery 2.1.4を追加
  • 認証には、データアクセスではなくインメモリ認証を使う
  • クエリパラメータはpだけを認識する

というわけなので、やってみる。

1. やること

いろいろググってみたところ、やらなければならないことは以下の3つだということが判明。

  1. 認証ハンドラを実装したクラスを用意し、その中でクエリパラメータを右から左にリレーする処理を書く。
  2. WebSecurityConfigurerAdapter の継承クラスで、1.で作成した認証ハンドラを使う設定を書く。
  3. Viewからのアクセスは、単純なsubmitやaタグのhrefで処理するのではなく、JSでaction属性やhref属性を直接操作する。

2. 実際には

2-1. カスタム認証ハンドラの実装

必要なカスタム認証ハンドラは、以下のとおり。

処理されるタイミング 実装するinterface
ログイン成功時 org.springframework.security.web.authentication.AuthenticationSuccessHandler
ログイン失敗時 org.springframework.security.web.authentication.AuthenticationFailureHandler
ログアウト時 org.springframework.security.web.authentication.logout.LogoutHandler

とりあえずログイン成功時だけ乗せるけど、HttpSevletRequestからクエリパラメータの文字列をとって、リダイレクトURLにぶら下げなおす、というのが基本。

public class AuthSuccess implements AuthenticationSuccessHandler {

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication authentication) throws IOException, ServletException {
    
    // add whole query parameters to url 
    String queryParams = request.getQueryString() == null ? "" : "?" + request.getQueryString();

    RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
    redirectStrategy.sendRedirect(request, response, "/menu" + queryParams );
  }

}

2-2. WebSecurityConfigurerAdapterに認証ハンドラをセット

Spring Securityを入れていると、その構成を行うJavaConfigがあるはずだが、そこをこんな感じに書き換える。

http.formLogin()
    .loginProcessingUrl("/login")
    .loginPage("/login")
    .successHandler(new AuthSuccess())  // ログイン成功時のカスタムハンドラ
    .failureHandler(new AuthFailure())  // ログイン失敗時のカスタムハンドラ
    .permitAll();

http.logout()
    .logoutRequestMatcher(new AntPathRequestMatcher("/logout**"))
    .addLogoutHandler(new LogoutPostProcess())  // ログアウト時のカスタムハンドラ
    .deleteCookies("JSESSIONID")
    .invalidateHttpSession(true);

それぞれカスタム認証ハンドラを設定するためのメソッドが用意されているので、そこにオブジェクトを放り込めばOK。

2-3. JSでURLをたたく

フロント系中心にやってる人にはどうということはないんだけど、通常 <input type="submit"> とか <a href="hogehoge"> とかで書いている処理を、JavaScriptでクエリパラメータを取得してURLをたたくようにすればOK。

/**
 * URLについているクエリパラメータを取得して、Formのaction属性を書き換えてsubmitする処理。
 */
function sendReq() {
  var arg = getQueryParam();
  
  var url = $('#loginForm').attr('action');
  if (arg.p != null) {
    url += '?p=' + arg.p;
  }

  $('#loginForm').attr('action', url);
  $('#loginForm').submit();
}

/**
 * URLについているクエリパラメータを取得する処理。
 */
function getQueryParam() {
  var arg = new Object;
  var pair = location.search.substring(1).split('&');
  for (var i = 0; pair[i]; i++) {
    var kv = pair[i].split('=');
    arg[kv[0]] = kv[1];
  }
  
  return arg;
}

/**
 * URLについているクエリパラメータを取得して特定ページへ遷移する処理。
 */
function doLogout() {
  var proto = location.protocol;
  var host = location.host;

  var url = proto + '//' + host + '/logout';
  
  var arg = getQueryParam();
  if (arg.p != null) {
    url += '?p=' + arg.p;
  }
  
  location.href = url;
}

3. とりあえず動かせる参照実装

GitHubに上げました。ご参照あれ。
f97one/AddingQueryParamsDemo

2022年8月
  1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31      

最近のトラックバック

無料ブログはココログ