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年6月27日 (土)

DELL Inspiron 5485 開封の儀(物理)

0. ようやくリプレース

いわゆる定額給付金でPCのリプレースを画策していて、条件に見合いそうなのがレノボIdeaPad C340くらいだったんだけど、ある日AI将棋をやっている知人の@bleu48さんから

....という煽りを食らうことに。ということで、

現物はこちらである。

1. そして着弾

そして待つこと3週間、ようやく着荷した。

外観は普通の段ボール箱だ。AMDのロゴがシールで貼り付けられている。Dsc_0356Dsc_0357

まぁ、ナローベゼルな14インチモデルなので外観はずいぶん小さく感じる。Dsc_0358

2. さあ、実験を始めようか(CV: 犬飼貴丈)

ここからが本題。

さてこのモデル、同じ給付金範囲内モデルのうちこれだけWindows 10 Proで出荷可能なんだが、選んだ理由はそれだけじゃない。ある一点に目をつぶれば大化けするのだ。

その一点とはメーカー保証がなくなるということ。上でも書いてたけど。

ということで、TEAMのDDR4 2666 16GB SO-DIMMを2枚と、Kingstonの1TB NVMe m.2 SSDを追加オーダー。Dsc_0362

開封にあたってはDELLが公開しているInspiron 5485 Service Manualをよく読んだうえで、自己責任であたる必要があることに注意。
なお、トルクスドライバーは不要で、普通の精密ドライバーだけでよい。

Dsc_0363
まず、本体裏のビスをすべて外す。先の写真のうち、赤丸部分はビスのねじ山が軸より太いので、裏ブタから抜けないことに注意。

Dsc_0372
次に本体を表にむけ、隙間に「薄くて硬いもの」を差し込んで少しずつこじ開ける。コツとしては、角付近からてこの原理で少しずつ力を加えていくと開けやすい。
僕はWAVEのパーツ・オープナーを使った。

Dsc_0364
裏ブタを外したところ。メモリスロット×2、m.2スロットに刺さったNVMe SSDのほか、3.5インチHDDベイまで見える。とりあえず、今回はメモリとNVMe SSDの換装をやる。

Dsc_0369
メモリは普通にスロットに刺さっているだけなので、左右の爪を広げると簡単に外すことができる。
Dsc_0370
そしてそのまま差し替えることで終了。

Dsc_0365
メモリとSSDを抜いたところ。メモリはSamsung製、SSDはSK Hynix製の40mmサイズだった。

Dsc_0366
SSDを固定するためのナットはいちばん外側に移動してやる必要があるが、これは刺さっているだけなので
Dsc_0367
こんな感じに移動してやる。

Dsc_0371
SSDも入れ替えたところ。SSDもちゃんと固定できている。今回はやらなかったが、3.5インチHDDベイにSATA SSDを入れることもできるので、ストレージの拡張性はまだまだありそうだ。

そしてふたを閉めて起動。BIOS画面に入らないといけないので、ついでにBIOSがハードウェアをちゃんと認識しているかを確認してみる。大丈夫そう。
Dsc_0373
Dsc_0374

このままWindowsを起動してみると、メモリはちゃんと認識されていた。
Photo_20200627004502

ストレージについてはGPTかつUEFIで作られたSystemRescueCdのUSBメモリで起動してパーティションを操作してやることで、すべての領域を利用できるようになった。
操作後はこうなった。
Photo_20200627004501

3. 肝心の操作感は

いや~、ずいぶん快適になりましたよ、ええ。

2020年6月12日 (金)

久しぶりにDIYやってみた

0. 机が狭いのである

現職での悩み、それは、微妙にデスクが狭いことだったりする。

僕はノートPCに外付けLCDとキーボードを増設して2画面構成にしているんだけど、ここ最近UIの設計でペーパープロトタイピングとかをやることがあって、そのたびに微妙な机のサイズが気になっていた。

LCDとかはデスクの上にそのまま載せていただけなので、ある日「LCDをのせる小さなテーブルがあれば、その下にキーボードをしまうことができるようになっていいのでは?」と思いついた。
ただ、そういうテーブルはUSBハブとかの機能がある代わりに割といいお値段するので、「キーボードを格納するだけなら自分で作れるっしょ」ということで、つくってみた。

1. 材料

最終的に以下のとおりになった。ちなみに、すべて近所のダイソーさんでそろえた。しめて11点、1210円(税込み)也。

  • MDFボード 400×200×6 1枚
  • 直方体 8枚入り 30×60×15 1組
  • 角材 450×10×21 3本
  • ステンレス金折隅金 38mmサイズ×2 2組
  • T字金具 小 4枚入り 1組
  • 皿木ねじセット 12mm~ 1組
  • 速乾性木工用ボンド 1本
  • 張れるペーパーボード 450×300×5 1枚

2. いざ制作

最初、MDFボードに直方体を釘打ちして止めればOKっしょ、と思っていたんだけど、愛用のLogicool Wireless Keyboard K360rの横幅がちょうど400mmほどで、そのままだとキーボードが入らないことが発覚。

なので、脚を横に張り出した形にするため、急遽角材を調達。同時に金折隅金とT字金具、および木ねじも補強用に調達。

脚となる直方体と梁になる角材をボンドでくっつけた後、補強用の金折隅金をねじ止めした図がこちら。

Dsc_0338
Dsc_0000_burst20200609123820937

そして、中心部分を補強するため残りの角材をカットして中心付近に渡してねじ止め。

Dsc_0339Dsc_0340
Dsc_0341

と、ここまでやって問題発生。
中心部分の補強に使った金具を止めるために使ったねじが、天板を微妙に貫通してしまったのだ。

Dsc_0342

飛び出ているのは約1mmほどなので、金工用やすりで削り倒してしまえばよかったんだけど、ちょうど手元になくてしかたなく目隠し用にペーパーボードを貼り付けることに。
クッション素材になっていてちょうどいい目隠しになってくれた。カッターナイフで簡単に切れるのもよい。

Dsc_0345

3. そして完成へ

ひとまず形になったので設置。我ながらいい感じだ。と自画自賛。

Dsc_0346
Dsc_0347

梁の作り方をもう少し工夫したり、脚の接合部分をのみで削るとかすれば、もう少しカッコよくなったかもしれない。

 

2020年6月10日 (水)

転職して半年以上経ったのでその前後を振り返ってみる

0. そういえばもう半年以上たってた

世の中の状況もいろいろ変わってるし、転職前後の自分を少し振り返ってみることにする。

自分の場合、家族の事情や自分が当時私的に抱えていた役職なんかもあるわけで、そのあたりをつまびらかにすると収拾がつかなくなるのと、これらの対策に必要なものは、極言すると自分の待遇に行き着くので、一番わかりやすい金銭面にフォーカスして書いてみることにする。

言い換えれば、キラキラした希望とかそういうものがあっての話じゃないことは、先に断っておく。

1. 前職でのざっくりした業務

前職での僕の動き(?)はざっくりこんなところ。あえてネガティブな書き方をしているのはご了承いただきたい。

  • 1年目 : 異業種からの転職組で特に技術があるわけでもなかったので、大手SIerにフィールドエンジニアの間接派遣としてねじ込まれる(いわゆるSES要員)
  • 2、3年目 : 同上
  • 4年目 : 同上、派遣先のプロパーの張った罠に引っかかってしまったことが転機になり、「これではだめだ」とようやく必死に勉強するようになる
  • 5年目 : 同上、必死に勉強したおかげで、つたないながらも自力でAndroidアプリを公開するまでに至る
  • 6年目 : 一次受けとプロパーとの間の契約が終了したため自社に戻る、社内でなぜかAndroidの第一人者に祭り上げられる
  • 7年目 : Androidの案件が結構降ってきていたので、ひたすらAndroidの案件をやるが、それもなくなってきてWebアプリの案件もやり始める
  • 8年目 : ある案件でいきなりPLが引き抜かれて、ところてん式にプロジェクトリーダーならぬプレイングリーダーになってしまい、自爆する
  • 9年目 : 自爆の影響か再びSES要員に、しかも入場先は県外で、また間接入場
  • 10年目 : 同上、転職を決意、水面下で活動を開始
  • 11年目 : プロパー側の入場者人員整理の結果、県外SES案件が終了、転職活動の成果が実り、ついに転職

こうしてみるとこの並び、キャリア形成としては碌なもんじゃないな、というのが正直な感想。

しかも、自社に戻る理由が自社からの引き揚げ指示ではなく、相手先の人員整理が理由というのも、はたから見たらただのお荷物にしか見えないだろう。
実際そうだったんじゃないの?と言われたらそうかもしれないが。

で、自分が仕事を通じてやりたいことと、実際に自分がやれることがそのまま一致する人はそうそういないんじゃないか、とも思うけど、今から思えばその「不一致による違和感」を顕著に感じだしたのは、上記でいう8年目~9年目のさなかに起きたある事件からだったように思う。

当時、n次受けとはいえご新規のWebアプリ開発案件が舞い込んできて、どういうわけかその要件定義を行うことになったのだが、「Javaを使うということ以外何も決まっていない」ということだったので、これ幸いとJava SE 8 + Spring Bootによる開発を推して見事これが採用された。

当時の僕は、うれしさのあまり

....というツイートを残していたのだが、この案件は自分がやりたい、と願っていたにも関わらずそれが本格的に滑り出す直前、自分が外されて県外SES要員になってしまった。

前職では、半年に1回程度1 on 1を上長とやるのだが、そのときに「この(Java 8 + Spring Boot)案件はお前にやってほしかったのにな~」という発言を上長から賜った。
なお、僕を県外SES要員にした人物と同一人物である。

県外SES案件にアサインされている間に、当時町内会長を拝命していたのにまともな活動もできずに終わることになり、そのうえ家庭内の状況も悪化の一途をたどることに。

家庭から悪い話を聞いたら自宅に帰って対応をするわけだけど、会社からは交通費が月あたり1往復分しか支給されないので、残りの交通費は自腹である。これが何気に重くのしかかる。

で、あるとき異変に気が付く。極端に給料が少ないのである。

家庭の状況は悪くなる一方だが、帰ってケアに当たろうにも移動のための交通費が捻出できない、というジレンマに陥ることになる。
結果、何もできない間に状況だけ悪くなる、という悪循環に陥ることに。

2. たまたまため込んでいたエビデンス

僕は前職での給与明細をほぼすべて保管していた。
何か目的があったわけでもなく、これはもうたまたまとしか言いようがなかったんだけど、「これは何かの縁だ」と思って手元にある給与明細の棚卸を始めることにした。

今回はMicrosoft SQL Serverを使ってみた。DDLはこんな感じ。

USE [salary]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[会社](
	[会社番号] [int] NOT NULL
	[会社名] [nvarchar](32) NULL
 CONSTRAINT [会社_pk] PRIMARY KEY NONCLUSTERED
(
	[会社番号] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO

CREATE TABLE [dbo].[給与履歴](
	[id] [int] IDENTITY(1,1) NOT NULL,
	[支給日] [date] NOT NULL,
	[差引支給額]  AS ([総支給額]-[控除合計]),
	[総支給額] [int] NOT NULL,
	[控除合計] [int] NOT NULL,
	[基本給] [int] NULL,
	[休日手当] [int] NULL,
	[時間外手当] [int] NULL,
	[深夜手当] [int] NULL,
	[その他]  AS (((([総支給額]-[基本給])-[休日手当])-[時間外手当])-[深夜手当]),
	[出勤日数] [float] NULL,
	[休出日数] [float] NULL,
	[有給使用日数] [float] NULL,
	[普通残業] [float] NULL,
	[深夜残業] [float] NULL,
	[特別休出日数] [float] NULL,
	[特別残業] [float] NULL,
	[会社番号] [int] NULL,
	[賞与] [bit] NOT NULL,
 CONSTRAINT [給与履歴_pk] PRIMARY KEY NONCLUSTERED
(
	[id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO

ALTER TABLE [dbo].[給与履歴] ADD  CONSTRAINT [DF_新給与履歴_総支給額]  DEFAULT ((0)) FOR [総支給額]
GO

ALTER TABLE [dbo].[給与履歴] ADD  CONSTRAINT [DF_新給与履歴_控除合計]  DEFAULT ((0)) FOR [控除合計]
GO

ALTER TABLE [dbo].[給与履歴] ADD  CONSTRAINT [DF_新給与履歴_基本給]  DEFAULT ((0)) FOR [基本給]
GO

ALTER TABLE [dbo].[給与履歴] ADD  DEFAULT ((0)) FOR [賞与]
GO

早い話このテーブルに給与明細の数字をINSERTして

select
datepart(year, [支給日]) as 支給年,
sum([差引支給額]) as 差引支給額計,
sum(総支給額) as 総支給額計,
avg(基本給) as 基本給平均,
max(基本給) as 最大基本給,
sum(普通残業) + sum(深夜残業) + sum(特別残業) as 残業計,
(sum(普通残業) + sum(深夜残業) + sum(特別残業)) / 12 as 月残業平均,
(sum(差引支給額) / 12) as 月平均手取り,
count(支給日) as 明細枚数
from 給与履歴
group by datepart(year, [支給日]);

みたいなSQLで分析していくわけである。まぁ、SQL Serverを使ったのは、「あまり使ったことがないものをあえて使うことで、やれることのすそ野を少しでも広げよう」というやつである。それ以外の意味はない。

これで約10年分の給与明細を入力したところ、いくつか見えてきた。

  1. 入社後5年間は基本給が上昇していない
  2. 同様に、入社後6年間は、月平均35時間程度残業していた
  3. 入社後7年目に給与テーブル改訂が行われた関係で基本給が上がったものの、その後はじりじり減っていた
    自爆後の下げ幅が一番大きかった
  4. 県外SES要員になってからは年合計で残業が60時間だった

早い話、自分の収入は長時間残業に支えられていたわけで、残業がほとんど発生しなくなった県外SES要員になってからは、その残業ブースト分がなくなったため給料が下がった、ということだ。

残業がないこと自体は素晴らしいことなんだけど、自分の仕事にはその程度のお金しかついてこないと考えると悲しいやら悔しいやら、分析していた当時は複雑な気分だった。

3. 「これではだめだ」(通算2度目)

と同時に、前職における僕の価値とはその程度だった、ということが改めて認識できたので、「これではだめだ。システムエンジニアとしての自分はこのままだと腐るだけだし、何よりも家族を養えない」とここでようやく転職を決意。

自分のスキルの棚卸をやり、効果的な職務経歴書の書き方を調べ、履歴書を送った会社は現職を含め10社だったが、うち面接までこぎつけたのは4社ほどしかなかった。

特に、ハロワ経由で紹介されたところは、履歴書を送っても音沙汰なしのほうが多かった。法律上、求人票を出さないといけないんだろうけど、採用するつもりはないということなのか。

スキルの棚卸については、カイゼンジャーニーという本で触れられていた星取表が役に立った。
これをもとに履歴書を書き、目当ての会社に履歴書を送るのだが、n次受け案件ばかり、しかもいわゆる下流工程ばかりをやってきた自分にとっては、職務経歴書をどう書くかでとても苦労した。そのまま案件を列挙したら、数だけ多くて何をしていたのかさっぱりわからないのである。

最終的には特徴的な部分だけ切り出して短いレジュメ形式にすることで、どうにか職務経歴書の体裁を整えることができた。

そして10社目にして、ようやく採用の内定をいただくことができた。調剤薬局の社内SEというポジションである。本当に運がよかったとしか言いようがない。

4. そして転職へ

ただ、前職には転職活動をするなど一言も言わずに、すべて気が決まってから「転職するので辞めさせてください」と切り出したので、そこから(大っぴらには出さなかったものの)裏切者扱いである。

すべてが決まってから退職を切り出した理由は単純だ。SES要員としての前職に未練はなかったことと、家族を養わなければならない手前、無収入になる瞬間の発生は是が非でも回避せねばならないことだったからだ。

とはいえ会社から見たら、あてにしていた人員が抜けることで事業計画の練り直しになってしまったわけだから、「会社に損害を与えた裏切り者」とみなされても不思議ではないだろう。気にならなかったけど。

Twitter界隈でも、SESの駒になっていた人の転職では一悶着あった話ばかり伝わって来るので、SESが絡む転職では100%の円満退職なんてものは幻想にすぎないのだろう。
もっとも、相当バイアスがかかっていそうではあるが。

現職に転職した後はどうなったかというと、収入も家族との時間も満足のいくものになったし、何よりも棄民同然のSESとは違い、自分の手が届く範囲で自分の技術でひとつずつ問題を解決していくところが性に合っていた。
これも生存者バイアスと言えばそうかもしれないが。

5. 得られた学び

  • 給与明細は残しておこう、いざというときに役に立つ
  • 転職サービスは複数のチャネルを持っておこう
  • 自分の棚卸は定期的にやろう

 

2020年5月30日 (土)

WSL2にした話

0. ついに Windows 10 version 2004 が GA

先日、ようやくWindows 10 version 2004がGAになったので入れてみた。
今回の目玉の一つはWindows Subsystem for Linux(WSL) version 2(以下「WSL2」)だろう。

僕はすでに既存のWSLでUbuntuを使っていたので、公式の移行手順をもとにUbuntuを更新を開始。

で、どうなったかというと

  • 手元の環境では移行に2時間40分ほどかかった(「数分かかることがあります」とはいったい....)
  • 手元のPCには16GBほどメモリを積んでいるんだけど、途中、vmmemというプロセスが10GBほどメモリを握りこんで動作が非常に緩慢になる

....てな感じではあったもののどうにか終了。変換が終了したら、vmmemが握っていたメモリは無事解放された。

ちなみにこのvmmemというプロセス、後で調べてみたら、どうやらこれが「WSL2が使っているマネージドVM」な模様。

1. Xクライアントを動かせるようにする

変換が無事終わったので WSLのUbuntuを起動。

僕はWindows用XサーバのVcXsrvをホスト環境に入れていて、Linuxのほうがいろいろと都合がいいPHPなんかは、WSLからPhpStormなんかを立ち上げて読み書きしているんだが、日本語入力ができないと何かと不便なので、WSLのシェルスタートアップ時にインプットメソッドのfcitxを起動するようにしている。

と、起動すると、「ディスプレイに接続できない」だの、「dbus-launchが異常終了して初期化できない」だの、思ってたのと違う状況に。

いろいろググりあげた結果、次のようにすることで解決した。

  1. ディスプレイに接続できない件は、DISPLAY環境変数の設定をlocalhostからresolv.confのアドレスに変えるQiitaの記事を参考にして解決
  2. dbus-launchが異常終了する件は、StackExchangeに同様のお悩みを抱えた方からの質問を参照して解決

2. どうしてこうなった

後追いでどうしてこうなったのか調べてみることに。

「WSLはAPI変換方式からマネージドVM方式に変わる」という話は小耳にはさんだことはあったのだけど、それがどういった影響があるのかまでは気にしていなかったのでいまさらなんだけど、ASCII.jpに懇切丁寧な解説記事があるのを発見。

記事を参照すると「WSL用の仮想スイッチが追加されている」ということなのでタスクマネージャーを見てみると、確かにvEthernet(WSL)というのが追加されている。

Task_mgr_1
一方、WSL内部でifconfigしてみたところ、ネットワークアドレスは172.27.128.0/20のようだ。プリフィックス20とは広くとってんな、というのが正直な感想。

  $ ifconfig
  eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
          inet 172.27.136.3  netmask 255.255.240.0  broadcast 172.27.143.255
          inet6 fe80::215:5dff:fe7a:7ea1  prefixlen 64  scopeid 0x20<link>
          ether 00:15:5d:7a:7e:a1  txqueuelen 1000  (イーサネット)
          RX packets 2699  bytes 421846 (421.8 KB)
          RX errors 0  dropped 0  overruns 0  frame 0
          TX packets 102  bytes 7288 (7.2 KB)
          TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

  lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
          inet 127.0.0.1  netmask 255.0.0.0
          inet6 ::1  prefixlen 128  scopeid 0x10<host>
          loop  txqueuelen 1000  (ローカルループバック)
          RX packets 0  bytes 0 (0.0 B)
          RX errors 0  dropped 0  overruns 0  frame 0
          TX packets 0  bytes 0 (0.0 B)
          TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

ただ、「再起動するとアドレスは変わるので一定にならない」そうなので、この値はあくまで参考値ということになる。

早い話、Windowsホスト環境とWSL内部はvEthernetのアドレスを接点にNATされている、ということのようなので、件のQiitaの記事で書かれている/etc/resolv.confのnameserverのアドレスを指定するやり方で問題ない、ということのようだ。

一方StackExchangeで紹介されているdbus-launchだが、DESCRIPTIONの冒頭には

dbus-launch コマンドは、シェルスクリプトから dbus デーモンのセッションバスインスタンスを開始するために使用されます。
通常は、ユーザーのログイン スクリプトから呼び出されます。デーモン自体とは異なり、dbus-launch は終了するので、
バックティックや $() コンストラクトを使用して dbus-launch から情報を読み取ることができます。

 

引数を指定しない場合、dbus-launch はセッションバスインスタンスを起動し、そのインスタンスのアドレスと PID を
標準出力に出力します。

....とある。WSL内で実際に実行してみると、シェル変数定義を二つ返す。

  $ dbus-launch
DBUS_SESSION_BUS_ADDRESS=unix:abstract=/tmp/dbus-Xq7M2S9YAw,guid=f6f9e17a76a4f8db225fc4bd5ed2165b
DBUS_SESSION_BUS_PID=389

manpagesの記載によれば、Xセッションの開始時にこれらの変数定義がないと新しいセッションを開始するようになっていて、事前定義するなどでdbus-launchによる自動起動を回避できるようだ。

fcitxがdbusを使っているので、dbus-launchの起動方法を工夫することはできるんだろうけど。

3. ところで使用感は

ざっくりこんなところ。

  • DockerをWSL2バックエンドに変えたら、明らかに起動が速くなったうえ、ホスト環境へのパフォーマンス影響も体感的には相当改善された、気がする
  • 従来のLXSSカーネルモジュールでの動作と比べ、Ubuntu Shellの起動は明らかに遅くなった
  • メモリをもりもり食うようになった
    ※WSL2の起動で2GBほど、Dockerを加えると+1.8GB、これにコンテナが乗るとさらに増える、といった感じ

ところで、WSL2で使うことになったアドレス範囲を内部ネットワークですでに使っている場合、どうなるんだろう?

» 続きを読む

2020年5月21日 (木)

今年もまたこのイベントがやってきた(通算47回目)

イベント is 何

言わずと知れた誕生日というやつである。

たまにはさらしてみようか、例の奴を。

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にしたい」という人も中にはいそう(※あくまで個人の見解です)だけど。

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

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

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

    最近のトラックバック

    無料ブログはココログ