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

2021年1月 9日 (土)

Vue.jsのプロジェクトをSpringに突っ込む

0. もう何番煎じだか

正直なところ、もう何番煎じだかわからないネタだけど、多分に自分用メモ。

今回目指したのはこんな感じのプロジェクトである。

  • フロントエンド側をVue.jsによるSPAにする
  • バックエンドはSpring BootによるREST APIサーバにする
  • フロントエンド、バックエンドとも、Gradleマルチプロジェクトとして一括管理する
  • フロントエンドの表示は、Spring BootのTomcatに同居させる

早い話、フロント用にWebサーバを用意するだけのリソースがないので一つにまとめてしまおう、というだけのことである。いうまでもないけれど、負荷分散とかを考えるならフロントとバックエンドを別のインスタンスに分離するなりの考慮は必要。

1. はじめてみる

できればIDEでちょいちょいとできればいいんだけど、やってみた感じCLIで少しずつ作っていくほうがやりやすかったので、今回はIDEを極力使わず、ターミナルとテキストエディタで対応する。

目標とするのはこういったディレクトリ構造にする。

./ → プロジェクトルート
├─backend/ -> APIサーバになるSpring Bootプロジェクト
|  └─build.gradle.kts -> バックエンドのguild.gradle.kts
├─front-vue/ -> フロントになるVue.jsプロジェクト
|  └─build.gradle.kts -> フロントエンドのguild.gradle.kts
└─build.gradle.kts -> ルートプロジェクトのguild.gradle.kts

1-1. 親プロジェクトを作る

ルートプロジェクトから作るわけだが、今回はマルチプロジェクトにするので、最低必要なものだけ生成する。

  > gradle init --type basic --dsl kotlin --project-name vue-with-spring

ビルドタイプをbasicにするのがミソ。こうすることで、何の設定も書かれていないbuild.gradle.ktsが生成される。

生成できたら、今後のために.gitignoreにIDE周りの無視設定を今のうちに入れておく。

また、システムに入れているGradleのバージョンが古い場合は、wrapperタスクで新しくしておくとよい。

  > ./gradlew wrapper --gradle-version=6.7.1

1-2. Vue.jsのプロジェクトを作る

次にVue.jsのプロジェクトを作る。Vue CLIでvue createすればよい。
Vue.jsのバージョンやらコンポーネントやらはお好みで。

  > vue create front-vue

生成したVue.jsのプロジェクトをGradle配下に置く。これには二つの工程がある。

  1. ルートプロジェクトのsettings.gradle.ktsにプロジェクトのインクルード設定を書く
  2. Vue.jsのプロジェクトにbuild.gradle.ktsを書く

一つ目については、単にinclude行を追加するだけだ。

  rootProject.name = "vue-with-spring"
  include("front-vue") // 追加

二つ目については、こんな感じのファイルを作った。

  import com.github.gradle.node.npm.task.NpmTask

  plugins {
      base
      // node-gradle プラグインで npm を扱う
      id("com.github.node-gradle.node") version("3.0.0-rc5")
  }

  // ``npm run build`` を実行するタスク定義
  tasks.register("npmBuild", NpmTask::class.java) {
      args.add("run")
      args.add("build")
  }

baseプラグインを導入することで最低必要なタスクが実行できるようになるのと、node-gradleプラグインを使うことでNode.jsとnpmをGradleのタスクとして扱うことができるようになる。

後の工程でnpm run buildを実行する必要があるので、これを行うnpmBuildタスクを用意した。

1-3. Spring Bootプロジェクトを組み込む

次に、Spring Bootのプロジェクトを作る。これはSpring Initializrで作る。最低必要なのはSpring Webだけど、プロダクション運用ならこれにデータアクセスとか、認証・認可とか、クラウド連携とかのライブラリが入るはず。

Spring_initilizr

今回はプロジェクトをGradleに、言語をKotlinにした。

この状態でGenerateボタン押すとプロジェクトがZipで降ってくるんだが、この中から必要なファイルだけ取り出し、バックエンドのプロジェクトに移植する。

Demo

バックエンドのプロジェクト自体はすでにGradleプロジェクトになっていて、Spring InitializrですでにGradle Kotlin DSLになっているので、追加したSpring Bootプロジェクトをルートプロジェクトの配下にするだけでよい。

  rootProject.name = "vue-with-spring"
include("front-vue", "backend") // 追加

1-4. Vue.jsのビルド成果物をSpringに取り込めるようにする

Vue.jsのプロジェクトはnpm run buildを実行すると、プロジェクト内の dist ディレクトリ内にプロダクションビルドした内容を出力する。
これをSpring側に反映すればいいのだが、毎度手でコピーしていると面倒極まりないのでビルド成果物を直接Springに出力するよう設定する。

これは、Vue.jsプロジェクト側にVue.js自体の設定ファイルであるvue.config.jsを作成する。(リファレンス

  module.exports = {
    outputDir: "../backend/src/main/resources/static/"
  }

要は、バックエンドプロジェクトのsrc/main/resources/static/に直接出力するわけで、SpringとしてはここにあるファイルをWebコンテナに取り込んで起動するので、これで動いてしまうわけだ。

あとはbootRunタスクを実行すれば、Vue.jsをフロントにしたサイトを表示できる。

  > ./gradlew :backend:bootRun

:backend:bootRunタスクの前処理に:front-vue:npmBuildタスクを実行するような設定を書いてやるのでもいいかもしれないが、開発中はVue.jsのプロジェクトとSpring Bootのプロジェクトを独立して動かして動作確認する、といったシーンも考えられる。
そういった場合は

  .PHONY: front-boot-run
  front-boot-run:
      ./gradlew :front-vue:npmBuild
      ./gradlew :backend:bootRun

みたいなMakefileを書いて、makeターゲットとしてしまうのがいいのかもしれない。

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万円以上の場合は印紙が必要になるのでテンプレートはそこで分ける必要があるが)
  • 受注結果をチャットのタイムラインに流す

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

2020年5月 6日 (水)

Spring Batchで改行がめちゃくちゃなCSVと戦う

0. まだまだSpring Batchと格闘中です

先日実際に起きたお話。

取引先からダウンロードしたとあるCSVをSpring Batchで書いた取込処理にかけたところ、ファイルの読み込みで死ぬケースが起きたので調べてみたところ、CSVのカラムに改行が入っていたことでFlatFileItemReaderがお亡くなりになっていた。
要はこんな感じである。

Csv_with_messy_line_breaks

実リリース前のテストで発覚したのでよかったものの、これはあかん、ということで、どうするべきかを考えることに。

1. 今回のお題目

  • 改行を含む可能性があるのは、特定のカラムだけ
  • 改行コードはバラバラ(CRLF、LF、CRのいずれもありうる)、かつエスケープされていない
  • データのカラムはダブルクォーテーションで囲まれている
  • 1行目はヘッダ行なので読み飛ばす必要がある
  • ....をチャンクモデルでどう扱うかを考えるわけだが、最大の問題は2番目のかつエスケープされていないという箇所。これがもとで、標準のDelimitedLineTokenizerArrayIndexOutOfBoundsExceptionでお亡くなりになってしまう。

    ちゃんとエスケープされているなら大丈夫だそうなんだが。
    TERASOLUNA Batch Frameworkのファイルアクセスの項を参照。

    そういう時にはCSVを読み込むカスタムReaderを作るべしということだそうなので、実際にやってみた。

    2. ItemReader / ItemProcessor / ItemWriter

    読み込みのときに問題があるので、まずItemReaderから。
    先ほどのQiitaの記事ほぼそのままで恐縮だが、使い慣れていたOpenCSVをパーサにしているのと、Builderを追加しているのが違うくらい。

      class CsvItemReader<T>() : AbstractItemCountingItemStreamItemReader<T>(),
          ResourceAwareItemReaderItemStream<T>, InitializingBean {
    
        var charset: Charset = Charset.defaultCharset()
        var linesToSkip: Int = 0;
        var delimiter: Char = ','
        var quotedChar: Char = '"'
        var escapeChar: Char = '"'
    
        private lateinit var resourceToRead: Resource
        private lateinit var headers: Array<String>
        private lateinit var fieldSetMapper: FieldSetMapper<T>
    
        private var noInput: Boolean = false
        private lateinit var csvReader: CSVReader
    
        init {
          setName(this.javaClass.simpleName)
        }
    
        override fun doOpen() {
          Assert.notNull(resourceToRead, "Resource to read is required")
    
          // 例外をスローするとバッチにブレーキがかかる
          noInput = true
          if (!resourceToRead.exists()) {
            throw IllegalStateException("Input resource does not exist : $resourceToRead")
          }
          if (!resourceToRead.isReadable) {
            throw IllegalStateException("Input resource must be readable : $resourceToRead")
          }
    
          // ここからOpenCSVの初期化
          // CSVParser
          val csvParserBuilder = CSVParserBuilder().withSeparator(delimiter)
              .withQuoteChar(quotedChar)
              .withStrictQuotes(true)
          // 同じ値を書き込むと怒られるので、不一致の場合のみにする
          if (quotedChar != escapeChar) {
            csvParserBuilder.withEscapeChar(escapeChar)
          }
    
          csvReader = CSVReaderBuilder(FileReader(resourceToRead.file, charset))
              .withCSVParser(csvParserBuilder.build())
              .withSkipLines(linesToSkip)
              .build()
    
          noInput = false
        }
    
        override fun doRead(): T? {
          // 読める状態にない、あるいは読んだ内容が空だったときは null を渡すと空データとして処理される
          if (noInput) {
            return null
          }
    
          if (csvReader == null) {
            throw ReaderNotOpenException("CSVReader is not initialized")
          }
    
          // OpenCSVで行を読む
          val line: Array<out String> = csvReader.readNext() ?: return null
    
          // FieldSetMapperに読んだ行を渡してPOJOにマップさせる
          val fs: FieldSet = DefaultFieldSet(line, headers)
          return fieldSetMapper.mapFieldSet(fs)
        }
    
        override fun doClose() {
          // 終了処理で呼ばれる
          // 各種パーサはここで閉じておくべし
          csvReader.close()
        }
    
        override fun setResource(resource: Resource) {
          // ResourceAwareItemReaderItemStream から。
          // これを実装しておくと、 MultiResourceItemReader の委譲先にすることができるようになるっぽい
          this.resourceToRead = resource
        }
    
        override fun afterPropertiesSet() {
          Assert.notNull(this.headers, "header is required")
          Assert.notNull(this.fieldSetMapper, "FieldSetMapper is required")
        }
    
        fun setHeaders(headers: Array<String>) {
          this.headers = headers
        }
    
        fun setFieldSetMapper(fieldSetMapper: FieldSetMapper<T>) {
          this.fieldSetMapper = fieldSetMapper
        }
    
        /**
         * 上記CsvItemReaderのビルダ。
         */
        class Builder<T>() {
          private val reader: CsvItemReader<T> = CsvItemReader()
    
          fun build(): CsvItemReader<T> {
            return reader
          }
    
          fun withResource(resource: Resource): Builder<T> {
            reader.setResource(resource)
            return this
          }
    
          fun withFieldSetMapper(fieldSetMapper: FieldSetMapper<T>): Builder<T> {
            reader.fieldSetMapper = fieldSetMapper
            return this
          }
    
          fun withHeaders(headers: Array<String>): Builder<T> {
            reader.headers = headers
            return this
          }
    
          fun withCharset(charset: Charset): Builder<T> {
            reader.charset = charset
            return this
          }
    
          fun withLinesToSkip(linesToSkip: Int): Builder<T> {
            reader.linesToSkip = linesToSkip
            return this
          }
    
          fun withDelimiterChar(delimiter: Char): Builder<T> {
            reader.delimiter = delimiter
            return this
          }
    
          fun withQuotedChar(quotedChar: Char): Builder<T> {
            reader.quotedChar = quotedChar
            return this
          }
    
          fun withEscapeChar(escapeChar: Char): Builder<T> {
            reader.escapeChar = escapeChar
            return this
          }
        }
      }

    ItemReaderとセットで使うFieldSetMapperは単純。たぶん、みればわかるレベル。

      @Component
      class CsvUserMapper: FieldSetMapper<CsvUser> {
        override fun mapFieldSet(fieldSet: FieldSet): CsvUser {
          // fieldSet の値を順番に抜いてはめて返すだけ
          return CsvUser(fieldSet.readString(0), fieldSet.readString(1))
        }
      }

    そのほか、中間処理をうけもつItemProcessorと書き込みを受け持つItemWriterも、話を単純にするためごく単純にしてみた。

    • ItemProcessor
      @Component
      class CsvImporterProcessor : ItemProcessor<CsvUser, AppUser> {
        override fun process(item: CsvUser): AppUser? {
          // 単にインスタンスを組み替えるだけ
          return AppUser(item.username, item.description)
        }
      }
    • ItemWriter
      @Component
      class CsvItemWriter(private val appUserRepository: AppUserRepository): ItemWriter<AppUser> {
        override fun write(items: MutableList<out AppUser>) {
          // こちらも右から左に永続化するだけ
          // AppUserRepository は AppUser の JpaRepository
          appUserRepository.saveAll(items)
        }
      }

    この状態でStepをSpring Beanとして構成してやる。

      @Bean
      fun csvItemReader(csvUserMapper: CsvUserMapper): ItemReader<CsvUser> {
        return CsvItemReader.Builder<CsvUser>()
                .withCharset(StandardCharsets.UTF_8)
                .withResource(ClassPathResource("/csv/userdata.csv")) // クラスパス内にあるファイルを指定している
                .withFieldSetMapper(csvUserMapper)
                .withLinesToSkip(1) // 1行飛ばす
                .withHeaders(arrayOf("username", "description"))  // ヘッダをマップするメンバーの定義
                .withDelimiterChar(',') // 区切り記号
                .withQuotedChar('"')    // 囲み文字
                .build()
      }
    
      @Bean
      fun step1(csvItemReader: ItemReader<CsvUser>, csvItemWriter: CsvItemWriter, csvImporterProcessor: CsvImporterProcessor): Step {
        return stepBuilderFactory.get("csvItemReaderStep")
                .chunk<CsvUser, AppUser>(10)
                .reader(csvItemReader)
                .processor(csvImporterProcessor)
                .writer(csvItemWriter)
                .build()
      }

    こうしてやることで、ようやく改行を含むカラムをちゃんと読めるようになった。
    DBに書き込んだ結果がこちら。

    Insert_result_1

    3. ソースコード

    https://github.com/f97one/LineBreakAwareCsvImporterDemoをご参照ください。

    2020年5月 4日 (月)

    WicketとSpringを悪魔合体させる その2

    0. ではがったいさせるぞ(通算二度目)

    WicketとSpringを悪魔合体させるアプローチとして、前回の記事では

    1. WicketをベースにSpringのDIを個別に組み込む
    2. Spring BootをベースにフロントをWicketにする

    のうち前者にチャレンジしてみたが、今回は後者をやってみる。

    1. 準備

    Spring Bootをベースにするので、まずは普通にSpring InitializrでSpring Bootなプロジェクトを作る。
    この時のポイントは、テンプレートエンジンは入れないこと。Apache WicketはViewよりの機能を提供するので、画面組立はWicketにさせるためだ。

    以下、pom.xmlのdependenciesを抜粋。

      <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-kotlin</artifactId>
    </dependency>
    <dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-reflect</artifactId>
    </dependency>
    <dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-stdlib-jdk8</artifactId>
    </dependency>
    <!-- Apache Wicket -->
    <dependency>
    <groupId>org.apache.wicket</groupId>
    <artifactId>wicket-spring</artifactId>
    <version>${wicket.version}</version>
    </dependency>
    <dependency>
    <groupId>org.apache.wicket</groupId>
    <artifactId>wicket-core</artifactId>
    <version>${wicket.version}</version>
    </dependency>
    <dependency>
    <groupId>org.apache.wicket</groupId>
    <artifactId>wicket-ioc</artifactId>
    <version>${wicket.version}</version>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
    <exclusion>
    <groupId>org.junit.vintage</groupId>
    <artifactId>junit-vintage-engine</artifactId>
    </exclusion>
    </exclusions>
    </dependency>
    </dependencies>

    2. 普通のSpring Bootアプリケーションからいじるところ

    まず、Spring BootにすることでKotlinのコードはsrc/main/kotlinに、ページに紐づくViewのHTML等それ以外のファイルはsrc/main/resourcesに、それぞれ配置されるようになるが、WicketのQuick Startで作ったプロジェクトではJavaのコードもページに紐づくHTMLも同じ場所に置かれるようになっている。

    ここは趣味の問題と言えるけど、Wicketのしきたりに従うなら、pom.xmlのbuildセクションをいじってHTMLをsrc/main/kotlin以下に置けるようにするといい。

      <build>
    <resources>
    <resource>
    <filtering>false</filtering>
    <directory>src/main/resources</directory>
    </resource>
    <resource>
    <filtering>false</filtering>
    <directory>src/main/kotlin</directory>
    <includes>
    <include>**</include>
    </includes>
    <excludes>
    <exclude>**/*.kt</exclude>
    </excludes>
    </resource>
    </resources>
    <testResources>
    <testResource>
    <filtering>false</filtering>
    <directory>src/test/resources</directory>
    </testResource>
    <testResource>
    <filtering>false</filtering>
    <directory>src/test/kotlin</directory>
    <includes>
    <include>**</include>
    </includes>
    <excludes>
    <exclude>**/*.kt</exclude>
    </excludes>
    </testResource>
    </testResources>
    <!-- 以下略 -->
    </build>

    またWicketには、クラシックなwarで使われるweb.xmlで設定している設定が必要になる。
    Create a Wicket Quickstartで作ることができるmvn archetype:generateの結果には、以下のようなweb.xmlが含まれているはず。

    <?xml version="1.0" encoding="ISO-8859-1"?>
    <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://www.oracle.com/webfolder/technetwork/jsc/xml/ns/javaee/web-app_3_1.xsd"
      version="3.1">
    
      <display-name>test-wicket-app1</display-name>
    
      <!--
        There are three means to configure Wickets configuration mode and they
        are tested in the order given.
    
        1) A system property: -Dwicket.configuration
        2) servlet specific <init-param>
        3) context specific <context-param>
    
        The value might be either "development" (reloading when templates change) or
        "deployment". If no configuration is found, "development" is the default. -->
    
      <filter>
        <filter-name>wicket.test-wicket-app1</filter-name>
        <filter-class>org.apache.wicket.protocol.http.WicketFilter</filter-class>
        <init-param>
          <param-name>applicationClassName</param-name>
          <param-value>net.formula97.webapps.WicketbootlinApplication</param-value>
        </init-param>
      </filter>
    
      <filter-mapping>
        <filter-name>wicket.test-wicket-app1</filter-name>
        <url-pattern>/*</url-pattern>
      </filter-mapping>
    </web-app>

    Spring BootにすることでJava Configで書くことができるようになるので、これをもとにServletContextInitializerの実装クラスをConfigurationにして書いてやる。

      @Configuration
      class ServletInitializer: ServletContextInitializer {
        override fun onStartup(servletContext: ServletContext) {
          // web.xml の設定をもとに書く
          // WicketFilter は Apache Wicket の ServletFilter
          val registration: FilterRegistration = servletContext.addFilter("wicket.wicketbootlin", WicketFilter::class.java)
          registration.setInitParameter(WicketFilter.APP_FACT_PARAM, SpringWebApplicationFactory::class.java.name)
    
          // メインクラスのFQCNをここで指定する
          registration.setInitParameter("applicationClassName", WicketbootlinApplication::class.java.name)
          // いわゆるサーブレットフィルタ部分
          registration.setInitParameter(WicketFilter.FILTER_MAPPING_PARAM, "/*")
          registration.addMappingForUrlPatterns(null, false, "/*")
    
          // 起動モード指定
          // spring.profiles.active の値に応じて development と deployment を切り替えるとかもアリだろう
          registration.setInitParameter("configuration", "development")
        }
      }

    最後にエントリーポイントになるWebApplicationだが、Spring Bootのエントリーポイントは、作った直後ではこうなっているはず。

      @SpringBootApplication
    class WicketbootlinApplication
    companion object { @JvmStatic fun main(args: Array) { runApplication(*args) } }

    なので、エントリーポイントのクラスをWebApplicationの継承クラスにして、getHomePage()とinit()を実装する。

      @SpringBootApplication
      class WicketbootlinApplication: WebApplication() {
        @Autowired
        private lateinit var applicationContext: ApplicationContext
    
        companion object {
          @JvmStatic
          fun main(args: Array) {
            runApplication(*args)
          }
        }
    
        override fun getHomePage(): Class {
          return HomePage::class.java
        }
    
        override fun init() {
          super.init()
    
          // レスポンスとマークアップ時のエンコーディングをUTF-8にする
          requestCycleSettings.responseRequestEncoding = CharEncoding.UTF_8
          markupSettings.defaultMarkupEncoding = CharEncoding.UTF_8
    
          // ComponentScanの結果を反映
          // これで Wicket から Spring による DIコンポーネントを使うことができるようになる
           componentInstantiationListeners.add(SpringComponentInjector(this, applicationContext))
    
          // todo ページルーティングを書く
          mountPage("/", HomePage::class.java)
        }
      }

    これでフロントがWicketのSpring Bootアプリケーションになったので、

      @Service
      interface EnterpriseMessage {
        fun getMessage(): String
        fun getVersionCode(): String
      }

    こんなServiceを

      class HomePage(params: PageParameters): WebPage(params) {
        // Page クラスは Spring の配下にあるわけではないので、
        // Autowired ではなく org.apache.wicket.spring.injection.annot.SpringBean を使う
        @SpringBean
        private lateinit var enterpriseMessage: EnterpriseMessage
    init { // ページに値を張る add(Label("message", Model.of(enterpriseMessage.getMessage()))) add(Label("versionCode", Model.of(enterpriseMessage.getVersionCode()))) } }

    とやることでPageクラスから利用できるようになる。
    ページに値を張った結果はこんな感じ。
    Injected

    あとは、ビジネスロジックやデータアクセスに関する部分は、Springのもつ強力な機能を活用すればいいだろう。

    2020年5月 3日 (日)

    WicketとSpringを悪魔合体させる その1

    0. ではがったいさせるぞ

    データアクセスがからむユニットテストをやるには、やはりDIの力を借りるのが手っ取り早い。

    CDIでもいいんだけれど、今回はSpringを使ってみることにする。
    アプローチとしては

    1. WicketをベースにSpringのDIを個別に組み込む
    2. Spring BootをベースにフロントをWicketにする

    の二つがあるが、今回は前者にチャレンジしてみる。

    1. 準備

    何はなくともpom.xmlの依存設定だ。wicket-springを依存に追加する。
    そのほか、SpringのDIをつかうので、Spring Contextとjavax.annotation-apiも加える。

      <dependency>
        <groupId>org.apache.wicket</groupId>
        <artifactId>wicket-spring</artifactId>
        <version>${wicket.version}</version>
      </dependency>
      <dependency>
        <groupId>org.springframework</groupId>
        <artifactid>spring-context</artifactId>
        <version>5.1.10.RELEASE</version>
      </dependency>
      <dependency>
        <groupId>javax.annotation</groupId>
        <artifactid>javax.annotation-api</artifactId>
        <version>1.3.2</version>
      </dependency>

    2. 各所の実装

    話を簡単にするため、今回はView側にDIでねじ込まれた値を張るだけにしてみる。こんな感じ。

    • HomePage.html
      <!DOCTYPE html>
      <html xmlns:wicket="http://wicket.apache.org" lang="ja">
      <head>
          <meta charset="utf-8" />
          <title></title>
      </head>
      <body>
      <h1>
          <span wicket:id="message">hello</span>
      </h1>
      <p>
          java.version = <span wicket:id="versionCode">8</span> .
      </p>
      </body>
      </html>
    • HomePage.kt
      class HomePage(parameters: PageParameters): WebPage(parameters) {
        // Autowired ではなく org.apache.wicket.spring.injection.annot.SpringBean を使う
        @SpringBean
        private lateinit var enterpriseMessage: EnterpriseMessage
    
        init {
          add(Label("message", Model.of(enterpriseMessage.message)))
          add(Label("versionCode", Model.of(enterpriseMessage.versionCode)))
        }
      }

    エントリーポイントになるWebApplicationの継承クラスは、DIしたいパッケージにコンポーネントスキャンをかけてSpringの管理下に置く処理を書く。

      class WicketApplication(): WebApplication {
        override fun init() {
          super.init()
    
          val ctx = AnnotationConfigApplicationContext()
          // コンポーネントスキャン対象を指定
          ctx.scan("net.formula97.webapps.beans")
          ctx.refresh()
          // Wicket-Springの機能でSpringの管理下に置く
          getComponentInstantiationListeners().add(SpringComponentInjector(this, ctx))
        }
      }

    Spring管理下に置かれたクラスは、今回は定数クラスっぽい扱いにしてみた。

      @ManagedBean
      class EnterpriseMessage {
        val message: String = "Welcome to the Spring-Integrated world!"
        val versionCode: String = System.getProperty("java.version")
      }

    こうしてやることで、マネージドビーンをnewすることなく(そもそもKotlinだとインスタンスを作るのにnewというキーワードは使わないのだが)使うことができるようになる。

    2020年4月28日 (火)

    Spring + Thymeleafで検索 + ページング + ソートを同時にやる件

    0. よく見るアレを自力で書く

    画面上半分に検索用の入力値を入れる部分があり、下半分に検索結果を表示するテーブルとページネーションバーが置かれている、ギョーミーアプリではよく見るアレを自力で書いたことがなかったので、その時のメモを。
    もう何番煎じなのかわからないけど、多分に自分用メモである。

    1. 動きの仕様的なもの

    ざっとこんな感じか。

    • 検索項目はPOSTで受け付ける
    • GETにクエリパラメータをぶら下げても検索できる
    • ページング処理はGETで行う

    2. 各所の要点的なもの

    今回は、総務省が公開している全国地方公共団体コードのExcelファイルをもとに、自治体コードと自治体名を検索表示するSpring Bootアプリケーションをネタにしてみた。

    なお、ソースコード全体はこちら

    2-1. 検索について

    検索項目はPOSTで受け付けるがGETにクエリパラメータをぶら下げても検索できるということなので、Controllerはこうなる。

        @PostMapping("/search")
        fun search(model: Model, @PageableDefault pageable: Pageable, condition: Condition?): String {
            // クエリパラメータを組み立てる
            val b: StringBuilder = StringBuilder("redirect:/?page=${pageable.pageNumber}")
    
            if (condition != null) {
                if (StringUtils.hasLength(condition.cityCode)) {
                    b.append("&cityCode=${condition.encodedCityCode()}")
                }
                if (StringUtils.hasLength(condition.cityName)) {
                    b.append("&cityName=${condition.encodedCityName()}")
                }
            }
    
            return b.toString()
        }

    ちと汚いが、POSTで受け付けたフォームデータをクエリパラメータに組みなおしてGETエンドポイントにリダイレクトしている。いわゆるPRG(POST-REDIRECT-GET)パターンである。

    肝心要の検索処理はGETエンドポイント側で行う。

        @GetMapping("/")
        fun show(model: Model, @PageableDefault pageable: Pageable,
                 @RequestParam(value = "cityCode", required = false) cityCode: String?,
                 @RequestParam(value = "cityName", required = false) cityName: String?,
                 @RequestParam(value = "sortItem", required = false) sortItem: String?,
                 @RequestParam(value = "direction", required = false) direction: String?,
                 @RequestParam(value = "sortDirection", required = false) sortDirection: String?): String {
    
            // 以下、クエリパラメータを一つずつ解析して検索にかける
            // :
            // :
            // 以下略
    
            return "/sorted"
        }

    @RequestParamアノテーションで受け付けるクエリパラメータを定義するのだけど、required = falseをつけて渡されなくてもエラーとしないようにする。

    また、ソートに関するパラメータもここで受け付ける。

    2-2. ページングに関して

    Springには、もともとページングに関する処理が入っていて、Repositoryにページ要求を渡すとページ情報付きで検索結果をもらうことができる。この辺りはSpring Web ページネーションあたりでググればいくらでも記事がヒットする。

    なので、Repositoryはこうなる。

    @Repository
    interface CityRepository: JpaRepository<City, String> {
    
        fun findCitiesByCityCodeIsLike(@Param("cityCode") cityCode: String, pageable: Pageable): Page<City>
        // その他いろいろ
        // :
      }

    引数のPageableが検索時に使うページ要求、戻り値のPageがページデータになる。

    画面のテンプレートエンジンには、戻り値のPageをそのまま張ればページネーションとして機能する。
    のだが、今回はあえてそれ専用のPOJOを用意した。

    View側になるテンプレートには、ページネーションバーに受け付けられるすべてのクエリパラメータを固めるようThymeleafのリンクURL式を書いてやる。このあたりはほぼhttps://blog1.mammb.com/entry/2018/06/04/225310そのまんまを拝借させてもらった。

    2-3. ソートに関して

    ソートもページネーションの応用でいける。

        <table class="table table-bordered">
            <thead>
            <tr>
                <th class="text-center">
                    <a href="#" th:if="${sortItem} eq 'cityCode'"
                       th:href="@{${url}(page=(${page.number} - 1), cityCode=${condition.cityCode}, cityName=${condition.cityName}, sortItem='cityCode', direction=${direction})}">
                        団体コード<span th:if="${sortItem} eq 'cityCode'"><span th:if="${direction} eq 'ASC'">▽</span><span th:if="${direction} eq 'DESC'">△</span></span>
                    </a>
                    <a href="#" th:unless="${sortItem} eq 'cityCode'"
                       th:href="@{${url}(page=(${page.number} - 1), cityCode=${condition.cityCode}, cityName=${condition.cityName}, sortItem='cityCode', direction='ASC')}">
                        団体コード
                    </a>
                </th>
            </tr>
            </thead>
        :

    前述のソートとあわせると、Controllerはこうなる。

        @GetMapping("/")
        fun show(model: Model, @PageableDefault pageable: Pageable,
                 @RequestParam(value = "cityCode", required = false) cityCode: String?,
                 @RequestParam(value = "cityName", required = false) cityName: String?,
                 @RequestParam(value = "sortItem", required = false) sortItem: String?,
                 @RequestParam(value = "direction", required = false) direction: String?,
                 @RequestParam(value = "sortDirection", required = false) sortDirection: String?): String {
    
            // ソートとページ要求は割と密な関係なので、条件を同時に整理する
            var sort: Sort? = null
            if (StringUtils.hasLength(sortDirection)) {
                val sd = if (sortDirection == Sort.Direction.ASC.name) Sort.Direction.ASC.name else Sort.Direction.DESC.name
                val si: String
                if (StringUtils.hasLength(sortItem)) {
                    si = sortItem!!
                    model.addAttribute("sortItem", si)
                } else {
                    si = "cityCode"
                }
                sort = Sort.by(Sort.Direction.fromString(sd), si)
    
                model.addAttribute("sortDirection", sd)
                model.addAttribute("direction", sd)
            } else if (StringUtils.hasLength(sortItem) && StringUtils.hasLength(direction)) {
                val dir = if (direction == Sort.Direction.DESC.name) Sort.Direction.ASC.name else Sort.Direction.DESC.name
                val searchDir = if (direction == Sort.Direction.DESC.name) Sort.Direction.DESC else Sort.Direction.ASC
                model.addAttribute("sortItem", sortItem)
                model.addAttribute("direction", dir)
                model.addAttribute("sortDirection", direction)
                val si = if (sortItem == "prefName") "pref.prefName" else sortItem
                sort = Sort.by(searchDir, si)
            }
    
            // ページ要求を作り直して検索
            val condition = Condition(cityCode, cityName)
            val p: Pageable = if (sort == null) {
                pageable
            } else {
                PageRequest.of(pageable.pageNumber, pageable.pageSize, sort)
            }
            val cities: Page<City> = citySearchService.searchCity(condition, p)
            val page = Pagination(cities)
            // クエリパラメータで渡された検索条件を画面のModelに張りなおす
            model.addAttribute("cityList", cities)
            model.addAttribute("page", page)
            model.addAttribute("condition", condition)
    
            return "/sorted"
        }

    3. 最後に

    受け付けるクエリパラメータを公開しておけば、トラブル対応もしやすいんじゃなかろうか。
    まぁ、クエリパラメータがURLにいっぱいぶら下がっていると「URLが汚くなるから全部POSTにしたい」という人も中にはいそう(※あくまで個人の見解です)だけど。

    それはそうと、ページネーションはサーバサイドではあまりコードを書かなくても実現できるけど、テンプレートエンジン側は結構めんどいことをやらないといけないのがつらい....。

    2020年4月 3日 (金)

    Spring Batchで複数ファイルをItemReaderに使う

    0. Spring Batchと格闘中です

    現職では、自力でバッチアプリを書いていて、なぜか思いのほかJavaと格闘している。

    利用シーンとしては、CSV提供されるマスタデータを複数のテーブルに格納する、というありがちな奴なんだけど、このCSVが一本ではなく複数ある。

    ということなので、最初はタスクレットモデルで複数ファイルを一気に扱う方式で実装していたけれど、メモリ使用量とかの問題でチャンクモデルで実装しなおすことに。

    と、ここで問題が。
    普通のFlatFileItemReaderでは複数のファイルを同時に処理できない。

    そこでMultiResourceItemReaderの出番です。

    1. MultiResourceItemReader

    Reads items from multiple resources sequentially (複数のリソースから項目を順番に読み取る) とあるとおり、処理を移譲されたItemReaderに順番にResourceを渡す。

    ソース全体はhttps://github.com/f97one/MultiFileItemReaderBatchExを参照いただくとして、肝になりそうな部分を抜粋してみる。

    ここでは、同じ書式の複数のファイルから二つのテーブルにデータを振り分ける処理を書いてみた。

    ItemReaderまわりのBean
      @Bean
      fun directoryOrgReader(orgReader: FlatFileItemReader<OrgFile>): MultiResourceItemReader<OrgFile> {
          // Resourceを配列として返す
          // 渡したくないファイルがあるならここでフィルタしておく
          val dir = Path.of("C:", "work", "readtest")
          val dirFiles = Files.list(dir).collect(Collectors.toList())
          val csvResList = mutableListOf<Resource>()
          for (p in dirFiles) {
              csvResList.add(FileSystemResource(p))  // プロジェクト外のファイルなので FileSystemResource を使う
          }
    
          val reader = MultiResourceItemReader<OrgFile>()
          reader.setResources(csvResList.toTypedArray())
          reader.setDelegate(orgReader)  // ここに入っているBeanで実際に処理される
          return reader
      }
    
      @Bean
      fun orgReader(): FlatFileItemReader<OrgFile> {
          val mapper = DefaultLineMapper<OrgFile>()
          val delimitedLineTokenizer = DelimitedLineTokenizer()
          delimitedLineTokenizer.setNames("id", "subject", "itemType")
          mapper.setLineTokenizer(delimitedLineTokenizer)
          val fieldSetMapper = BeanWrapperFieldSetMapper<OrgFile>()
          fieldSetMapper.setTargetType(OrgFile::class.java)
          mapper.setFieldSetMapper(fieldSetMapper)
    
          // 本来のFlatFileItemReaderには処理するResourceを指定する必要があるが、
          // delegateしてくるMultiResourceItemReaderからResourceを供給されるので
          // ここには何も書かなくてよい
          return FlatFileItemReaderBuilder<OrgFile>()
                  .name("orgFileReader")
                  .linesToSkip(1)
                  .encoding("windows-31j")
                  .lineMapper(mapper)
                  .build()
      }
    実行ステップ定義
      @Bean
      fun step1(directoryOrgReader: MultiResourceItemReader<OrgFile>, orgItemWriter: MultiTblItemWriter): Step {
          // ここではItemProcessorは定義していないが、必要に応じて追加しよう
          return stepBuilderFactory.get("step1")
                  .chunk<OrgFile, OrgFile>(10)
                  .reader(directoryOrgReader)  // MultiResourceItemReaderのほうをreaderにする
                  .writer(orgItemWriter)       // MultiTableItemWriterはItemWriterを実装したクラス
                  .build()
      }

    ItemWriterについてはよくあるやつなので、GitHubのほうを観てもらえば雰囲気はわかると思う。

    え? 読み込ませるファイルに順序がある? そんなときはComapratorを実装すればいい。

      @Bean
      fun directoryOrgReader(orgReader: FlatFileItemReader<OrgFile>): MultiResourceItemReader<OrgFile> {
          // Resourceを配列として返す
          // 渡したくないファイルがあるならここでフィルタしておく
          val dir = Path.of("C:", "work", "readtest")
          val dirFiles = Files.list(dir).collect(Collectors.toList())
          val csvResList = mutableListOf<Resource>()
          for (p in dirFiles) {
              csvResList.add(FileSystemResource(p))  // プロジェクト外のファイルなので FileSystemResource を使う
          }
    
          val reader = MultiResourceItemReader<OrgFile>()
          reader.setResources(csvResList.toTypedArray())
          reader.setDelegate(orgReader)  // ここに入っているBeanで実際に処理される
          reader.setComparator { o1, o2 ->
              // o1, o2 とも nullable な Resource のインスタンスなので、
              // getFile() なり getFileName() なりして比較結果を
              // Int で返してやろう
              return 0
          }
          return reader
      }

    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

    2021年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        

    最近のトラックバック

    無料ブログはココログ