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年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月 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
      }

    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
        }
      }

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

    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        

    最近のトラックバック

    無料ブログはココログ