« 2020年1月 | トップページ | 2020年5月 »

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
  }

« 2020年1月 | トップページ | 2020年5月 »

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

最近のトラックバック

無料ブログはココログ