リファクタリングの価値の考察

リファクタリングには価値がある、とプログラマは確信していることだろう。しかし、その価値が何であるか?を上手く説明できるかというと難しいのではないだろうか。本稿ではリファクタリングの価値をテーマに筆者の説を提示していく。

 

 

品質特性の側面から

 

ソフトウェアの品質特性としてISO/IEC 9126が一般的に用いられている。大きく6つの特性と細分化された副特性からなり、ISO/IEC 9126 - Wikipedia から引用すると

 

  • 機能性(functionality) - 機能とその特性に影響する特性群
  • 信頼性(reliability) - ある状況がある時間続いたときにソフトウェアがどの程度機能するかに影響する特性群
  • 使用性(usability) - 利用するのにかかる手間、個人の努力などに影響する特性群
  • 効率性(efficiency) - ソフトウェアの性能やそれに要するリソース量に影響する特性群
  • 保守性(maintainability) - 何らかの変更を加えるのにかかる手間に影響する特性群
  • 移植性(portability) - 別の環境にソフトウェアを移行させる可能性に影響する特性群

となる。これらのうち、リファクタリングの影響を受けるのは主に保守性(maintainability)となるだろう。

 

ここで「リファクタリング」の定義について振り返っておきたい。リファクタリングのバイブルとされるマーチン・ファウラーの書籍「リファクタリング」からその定義を引用しよう。( ISBN4-89471-228-8 pp.53-54 )

リファクタリング(名詞):外部から見たときの振る舞いを保ちつつ、理解や修正が簡単になるように、ソフトウェアの内部構造を変更させること。

 

リファクタリング(動詞):一連のリファクタリングを行って、外部から見た振る舞いの変更なしに、ソフトウェアを再構築すること。

 

この定義からしても、品質特性の保守性をターゲットとしたものであることが分かるだろう。

保守性に含まれる副特性を以下に挙げておく。

 

  • 解析性(analyzability)
  • 変更性(changeability)
  • 安定性(stability)
  • 試験性(testability)
  • 標準適合性(compliance

 

引用元は旧版である。新装版が出ているので書籍の購入を検討している方は新装版を確認してみて欲しい。

 

www.amazon.co.jp

 

補足 品質特性の相互作用

 

過去の記事で取り上げているのだが、 ソフトウェア要求 | Karl.E.Wiegers, 渡部 洋子 |本 | 通販 | Amazon という2003年の書籍では「品質属性の相互作用」についての記述があった。品質属性(この書籍は ISO 9126 準拠で記載されていないので用語が異なっているし特性の分類も異なっている点があるので注意)のうち、一方の属性を上げると、他の属性に+ないし-の変化をもたらすことが記載されていた。

 

nagise.hatenablog.jp

 

この相互作用のISO 9126準拠版が欲しいのだが、ご存じの方がいれば教えて頂きたい。

この書籍の相互作用表では「保守性」と「試験性」が挙げられている。前述の ISO 9126 では「試験性」は「保守性」の副特性という形になっているので注意されたし。

 

「保守性」が向上した際の相互作用としては、「可用性」「柔軟性」「信頼性」「試験性」に+効果、「効率性」に-効果となっている。

 

「試験性」が向上した際の相互作用としては、「可用性」「柔軟性」「保守性」「信頼性」「使用性」に+効果、「効率性」に-効果となっている。

 

本稿では、リファクタリングは「保守性」(その副特性である「試験性」を含む)へと直接作用し、品質特性の相互作用によりその他の特性に間接的な影響を及ぼす、という立場をとる。

 

よって、間接的な影響を及ぼす「保守性」「試験性」以外の品質特性については本稿では扱わない。読者の方々から検討してくださる方が現れてくださるとうれしい。

 

リファクタリングの価値

リファクタリングの効果は品質特性でいうところの「保守性」であるということをまず述べた。これはつまり、作った後に「保守」が生じなければ価値がないということでもある。

 

これはきしださん @kis が

 

まず、リファクタリングはそれ自体では価値を示せません。人工衛星に搭載するプログラムで、動きだしたらメンテナンスできないようなコードを最後にリファクタリングしたとして、どのような価値を示せるかと考えると想像できるのではないかと思います。

きしだのHatena

 

と述べているが、こうした事情を鑑みたものだろう。

 

障害対応

 「保守性」の副特性のうち「解析性(analyzability)」「安定性(stability)」は障害対応などに影響してくる。

 

 ソフトウェアのビジネス的な価値というのは、そのビジネスがどれだけの金を産み出しているかに依存する。おなじ機能のソフトウェアだとしてもA社で使われるのとB社で使われるのでは価値が異なってくるということだ。

 

 A社でそのソフトウェアが機能不全を起こして1日止まった場合、しかしその被害は1万円程度だったとしよう。対してB社ではそのソフトウェアに大きく依存しており1億円の被害となった。同じソフトウェアであるが、その運用保守にかけられるコストはA社とB社で異なってくるだろう。

 

リファクタリングのコスト < 障害時のダメージ軽減効果

 

と考えられるなら、リファクタリングを行う価値が十分にある。

 

 障害というのはまず起こるかどうか?が未来の不確定要素であるわけだから、この確率をどうみるかが難しい。このあたりが判断を難しくする理由のひとつだろうか。

 

 また、「解析性」「安定性」を高めて備えておいたことが、備えてなかった場合と比べてどれほどの価値を産んだか?というのも計測が困難である。こうした部分をどう価値判断するか、その価値判断をビジネスサイドとすり合わせることができるかが課題であろう。

 

機能追加

 

 「保守性」の副特性のうち「変更性」はまさに機能追加といった際のコストとなって現れる。逆説的には機能追加などせず外的要因で動かなくなったならば打ち捨てるという確たる信念で運用しているのであれば、「変更性」などどうでもよい。

 

 筆者はしばしば「技術的負債の踏み倒し」という表現を用いるが、うまく打ち捨てて作り直しをするという戦略がちゃんと回るなら、それはそれで立派な戦略だと考えている。

 

 打ち捨てて作り直しするつもりが、いざ作り直しが必要になったときに金銭的コストにびびって「既存のものの改造で済ませられないか?」と打診するようなのが一番ダサい。そうなった際のコストを度外視することでこれまでコストを削ってきたものを全て台無しにする戦略の一貫性のなさがデスマーチを産み出すのだ。言わんこっちゃない。

 

 機能追加なり改修なりというのは、事前に予見できない性質のものである。あるいはほんのりと「こういうことがしたいなあ」という構想があって備えておくようなケースはあるが、詳細を詰めると事前の準備では不十分というのが通例である。また、構想が日の目を見ず、備えてあった拡張性が役立つことなく製品寿命を終えることも少なくはない。

 

 ここでも確率論がでてくる。コスト算定がしにくい理由のひとつだ。ごく簡単には

 

リファクタリングのコスト < 機能追加のコスト軽減効果

 

ということになろうが、この「機能追加のコスト軽減効果」が容易に算出できない。

 

 また、需要と供給の話で良く出てくる需要曲線・供給曲線とその交点の話があるが、システムの機能追加の話でも似たようなものがあり、品質特性「変更性」が高いとなると機能追加しようとしたときのコストが下がる。供給曲線そのものが全体に下にさがるわけで、ならば買おう、という判断になったりするわけである。

 

(この例えが微妙なところは横軸が需要曲線・供給曲線では数量であるところが、システムの話にしようとしたときに横軸はなんだっけ?となる点である。より工夫が求められる。何かアイデアがあればご提示いただきたい)

需要と供給

 つまり、単に機能のビジネス的価値と、製造原価が折り合うか?という話から、そもそも製造原価そのものが下がっていたら?という話が混ざりこんでくるので複雑になる。

 

 この製造原価を下げる効果、いろんな機能追加がビジネス的に採算に乗りやすくなってくるわけだから、ユーザー要望に対する即時性を備えたいならば必須となろう。逆に、10年以上の安定稼働を前提とした生活インフラ系の保守管理用のシステムのような機能追加・改修みたいなものをあまり求められないシステムのような例だと、こうした部分に言及してアピールしても顧客にはその価値が響かない。

 

 これがB2Cシステム(Business to Customerの略で企業と一般消費者の取引のこと)だと、ユーザーの反応を見て素早い対応をすることが強く求められるので、最初からある程度の「変更性」を備えたプロジェクト運用をしていないとユーザー満足度を高められない。日頃からの不断のリファクタリングによって「変更性」をキープし続ける必要がある。

 

 それでも劣化して「変更性」が失われてくると、ユーザー要望への追従が出来なくなり、そうしたサービス品質の低下がユーザー離れを加速し……といった負のスパイラルに入ることがある。資本力があれば、そうしたタイミングで抜本的な対策として大規模な作り直しが行われることがある。こうした「システムの製品寿命」がどれぐらいか?というところに「変更性」が関わってくる。

 

システムの製品寿命

 システムというのは、初期に大きく資本を投じてわーっと作り、その後は細々と保守運用フェーズになるというライフサイクルが多い。

 

 単純な例となるが、10億投じてシステムを作り、5年運用したところで「変更性」が失われビジネス要件の追従が限界であるとして新システムを作るという話になるのと、これが10年メンテしながら運用できるのでは、10年もった方がお得である。

 イニシャルコスト(初期コスト)を運用年で割って、単年度あたりに慣らすと、5年運用なら年度あたり2億円だし、10年運用なら1億円だ。

 

 このようにシステムの寿命を延ばすという点で「変更性」や「試験性」が効いてくるわけである。これらの価値がいかほどか?ということになると、結局のところそのシステムを使った場合のビジネスがどれほど金を産むかに依存する。

 

 大金を産み出すシステムであれば、できるだけ長く運用し続けたい。しかし、システムと言うのは外的要因で使えなくなったりすることもある。例えば税制が変わったりとか、セキュリティ上の理由で採用していたプロトコルが使えなくなって新プロトコルに切り替えなくてはならないとか。PCが廃れてスマホに対応しないといけなくなった、なんてのは2010年代にあったムーブメントである。こうした世間の動向もシステムの寿命を縮める外的要因である。

 

 しかし、十分に「変更性」が高ければこうした変更についていける。こうした事態のたびにシステムをいちから作り直すのに比べればコストは安く済むはずであるし、対応までのリードタイムも短くて済むことだろう。そうした「価値」に「保守性」はつながっているし、その「保守性」に「リファクタリング」は繋がっている。

 

まとめ

  • リファクタリングは品質特性の「保守性(maintainability)」に作用する
  • 保守性の価値は、システムのビジネス上の価値と関係している
  • ビジネス上の価値や未来の可能性みたいなものが関連してくるので価値の算出は容易ではない
  • 筆者の説は叩き台のようなものであるから、不備の指摘や異論を提示してもらえると、このテーマについてみんなの理解が深まるのではないか

 

 

 

 

 

なぜ自動テストの導入は失敗するのか?

 開発室の雑談。営業側のマネージャが言うには
「今のプロジェクトで自動テストの導入を試みている話をしたら、XXXさんのところでも過去にいくつか導入を試みたけどもみんな上手くいかなかったって話になって」

 なるほど?

 まあ確かに自動テストはシステム開発にとって魅惑の技法ではあるものの、では導入がうまくいっているか? というと普及率は低いと言わざるを得ない。私がお手伝いしたプロジェクトでは、元請け側から自動テストをやるお達しが来たわけだが、紆余曲折あって掛け声倒れのような状態になってしまった。

 ビジネス書の煽りタイトルのような本件だが、古式ゆかしき受注生産の業務システム開発プロジェクトに自動テストを導入しようとして失敗する事例を聞いたので、僕なりに分析して見出した要素を挙げておこうと思う。

V字モデル

 ソフトウェア開発の手法としてV字モデルというものがある。

 オーダーメイドでシステムを作るにあたって、どのような要求があるか、どう設計すればよいか、それを段階を踏んで細分化・詳細化していき、プログラムのコーディングに至り、そして単体テスト結合テスト、とパーツ単位の検品からその組み合わせとしての試験、といったように進めて成果を上げていくわけである。

f:id:Nagise:20210429133857p:plain
V字モデル概念図

CC 表示-継承 3.0, https://ja.wikipedia.org/w/index.php?curid=894431


 このV字モデルと、いわゆるウォーターフォール開発(定義や本来の意味は?といったことに踏み込むと難しいのでここでは触れない)において、自動テストの導入のネックとなるのはなんであろうか?


自動テストとは?

 自動テストとはこの文脈ではコンピュータ・プログラムが想定通りに動くかテストする工程を自動化するものだが、自動化といってもピンとこないかもしれないので簡単に触れておこう。全自動のコンピュータが何とかしてくれるという話ではない

 例えば、ふたつの数字を渡すとその和を返す機能を考えてみよう。

public int add(int x, int y) { … }

 これを試験する場合、ふたつの数字x, y にさまざまな値を渡して、得られる答えが期待通りかを確認することになるだろう。

  • 1, 2 を与えれば 3
  • -1, 1 なら 0
  • -2, -3 なら -5

 といった具合に。とはいえ、ひたすら並べていってもバグ検出の効果が薄いので、特徴のあるテストケースを用意するなどの工夫は必要だ。まあ、その辺の話も割愛。

 ともかく、こうした入力と出力があって、それが予見可能であれば、プログラムを呼び出してそういう値になるよな? ということを確認するプログラムが書ける。

assertEquals(3, add(1,2));
assertEquals(0, add(-1,1));
assertEquals(-5, add(-2,-3));

 こうした検証のためのプログラムが2021年時点のIT界隈で「自動テスト」と呼ばれるものの本体であって、「自動テストの導入」というのはこうした検証用のプログラムを書くことに他ならない。

 つまり、本来のプログラムのほかに、その検証用のプログラムを別途用意するというわけである。

 この検証用プログラムがうまく作れれば

  • 以後のテストは検証用プログラムを実行するだけで済む
  • 人力で膨大なテストを行うのに比べればコストダウンできるだろう
  • テストフェーズのリードタイムが短くなるだろう
  • 人力に比べヒューマンエラーが抑えれるだろう
  • 上記の総合的な作用により品質が向上するだろう

ということを夢見ていくつものプロジェクトが玉砕してきたというわけだ。


テスタビリティ

 先の自動テストのための検証用プログラムは非常に簡単な例だった。

 しかし、世の中にはテストしにくいプログラムというものがあって、検証用プログラムを書くことが非常に難儀することがある。ここでテストしやすさ、テスタビリティ(testability)という概念が出てくる。


 テストしにくいとはどういうことか? いろんなケースがあるが、テストしにくいモノのひとつには「状態を持つ」プログラムが挙げられる

 工学系の学部などで科学の学生実験とかやらされた人は身に染みていると思うが、「同じようにやって同じようになる」ためには、前提が揃っていることが大事である。

 状態を持たないプログラムを対象にして自動テストのための検証用プログラムを書くことは簡単である。同じ値をつっこめば、いつも同じ値を出力する。 1 + 2 = 3 であり、冪等(べきとう、何度繰り返しても同じ性質)である。

 しかし、内部で状態を持つプログラムは同じように操作したつもりでも突然違う挙動を示す。電卓でボタンを順に 1, +, 2, = と押した場合、3と表示されるだろうか? そのテストを始める前に誰かが 5, + と入力した状態から始めたらどうなるだろう? 期待される答えである 3 ではなく、8 が表示されてしまう。

 状態をもつプログラムのテストしにくさはこういうところで出てくる。まあ電卓の場合はCボタンを押してクリアしてからスタートすればいいわけだけども、じゃぁデータベースはどうしたら良い? データベースにリセットボタンが必要になってくる。データベースにテスト用のリセットボタン、用意してありますか?



 これは一例でしかないが、テスタビリティというのはなんとなくで上がる話ではなくて、テスタビリティを上げるための技法を駆使しなくては上がらないということの一端が分かっていただけただろうか。V字モデルでいえば基本設計、機能設計、詳細設計といった設計の工程で、あらかじめテスタビリティを上げるための配慮した設計をしておかねばならない。


自動テストはいつ作られるのか?

 冒頭に挙げたV字モデルの概念図では、要求分析に始まり、基本設計、機能設計、詳細設計と細分化してからコーディングを行い、単体テスト結合テストシステムテスト、受け入れテストと進んでいくように描かれている。

 オーダーメイドでシステムを作ろうとした際、こうした工程を経てシステムを作りましょうという提案がされることが多いことだろう。

 では、「自動テスト」を導入するとした場合、自動テストはこのV字のどこで作られるのだろう?



 単純に手動での単体テストを置き換えるものと位置付けるのであれば、V字モデルの「単体テスト」のフェーズで自動テストを作成するということだろうか? プログラムを一通り作り終えて、単体テストフェーズに移りましょう!というタイミング?

 😩😩😩

 この案、聞いただけでうんざりした顔をした人も多いのではないだろうか。



「導入を試みたけどもみんな上手くいかなかったって」

 そりゃそうだろう。うまく行くわけがない。



 いわゆるウォーターフォール開発の手法によって、書類で工程を分離して記録を残すやり方だと、設計フェーズと、コーディングと、テストフェーズは明確に分かれている。「分離することで上手くやってきた」と語る人もいることだろう。

 そうか、これが大きな阻害要因だったか。


自動テストが破壊するもの


 自動テストにはプログラムを「部分的に実行する」という意味合いもある。プログラムを書いて、動かしながら、その動きが狙い通りか確認しながら仕上げていく。この「動かしながら」のためには巨大システムの一部のパーツを、その一部分だけで動かす技法が必要で、自動テストのためのテスティングフレームワークがその答えの一つだ。

 プログラムを書きながら、自動テストのための検証用プログラムもまた並行して書いて、自動テストを用いることでプログラムを実行しながら進めていくというスタイル。その究極はTDD(テスト駆動開発)だけども、そうでなくともコーディング中から自動テストを書くのが良いやり方だ。

 早めに動かして、早めにバグを検出できるならそれに越したことはない。

 しかし、これはV字モデルにおけるコーディングフェーズと、単体テストフェーズを曖昧にする。従来のV字モデルのやり方で、明確に分離された単体テストフェーズに自動テスト作成を試みるようでは、自動テストの効能が十全には発揮されない。自動車を買って馬につないで牽かせるような話だ。



 また自動テストは、いかようなプログラムにも後付けで用意できるわけではない。これは先にテスタビリティの部分で解説した。自動テストをやるには自動テストしやすい設計である必要がある。極力ステートレスにするとか、モックに差し替えが可能なようにDIコンテナを駆使するとか。そこにはいろんな技法があって、そうした技法を凝らすことでシステムを自動テスト可能となるよう、先人たちが工夫を重ねてきた。

 それでも、より具体的なプログラミングを行っていると、後になってから「これはあっちに持って行ったほうがいいな」ということは多発するし、自動テストのために設計変更したくなることも生じてくる。これはV字モデル的にはコーディングフェーズから詳細設計フェーズへの手戻りだ

 また、自動テストの効能のひとつに「リファクタリングを可能にする」という大きなポイントがある。詳細設計フェーズとコーディングフェーズの分離はこの開発時のリファクタリングを大きく阻害する


 自動テストを活かすには、詳細設計フェーズとコーディングフェーズが分離されていてはいけない。コーディングフェーズと単体テストフェーズが分離されていてもいけない。



自動テストの導入のためには


 本稿の主張は、V字モデルこそが自動テストの阻害要因であるというものである。であるから、V字モデルに拘泥する限り、自動テストの導入は成功率が低いし、よしんば導入できたとしても効果が低く成果が上がらないものとなるだろう。


 おそらく、V字モデルでいうところの詳細設計~コーディング~単体テストのフェーズを再考する必要がある。この部分を大きく改革しないと自動テストの恩恵を得ることは難しいだろう。


 ある程度のスキルレベルにあるメンバーで行われるプロジェクトであれば、この詳細設計~コーディング~単体テストの一連の工程を、プロジェクトメンバーがうまいことやってくれていた。しかしこれは、そういう練度のプログラマで構成されたプロジェクトであればこそだろう。


 となれば。より低スキルの寄せ集めメンバーでベルトコンベアの流れ作業のようにシステムを作ろう! という思想でやるのであれば。少なくとも旧来のV字モデルの詳細設計~コーディング~単体テストに変わる新しい分業モデルを検討しなくてはならないはずだ。

修正履歴

  • 2021/04/29 自動テストのコードの値の誤りを修正

Javaのリテラル

Q. リテラル(literal)とは何か?
A. 言語による

なんでこんな記事を書いているかというと、リテラル禁止FizzBuzz大会がいまいち盛り上がらなかったので、ルール解説をして参加者を増やそうという目論見である。

Java言語におけるリテラル

プログラミング言語一般で「リテラル」というと

コンピュータプログラミング言語においてリテラルは、ソースコード内に値を直接表記したものをいう。言語によってリテラルとして表記できる型の種類や表記方法は異なる。

リテラル - Wikipedia

という感じで、 42 とか "hoge" とかそういう値を直書きしたものを言う。このあたりは雰囲気で把握している人も多いだろう。ではその明確な範囲としてどこまでがリテラルか?となると、言語ごとのルールブック、つまるところ言語仕様に当たらなければならない。

Java言語仕様は怖くない。インターネットでオープンにされており、非常にアクセスしやすい。 Java SE Specifications のページに各バージョンの言語仕様へのリンクがまとめられている。

2021年9月には次期LTS(長期サポート)版のJava17がリリースされるが、本稿執筆時点でのLTSはJava11なのでJava11の言語仕様を元に調べてみよう。
物事を調べるには原典にあたることが大事である。原典をみて誰かが本を書いたりblogを書いたりする。それを見て書かれたblogは原典を見たものより信頼度が落ちる。孫引きして書かれた引用元も示されてないような記事を信頼してはしてはいけない。

"The Java Language Specification, Java SE 11 Edition" の "HTML"のリンクを辿ると The Java® Language Specification Java SE 11 Edition の目次に辿り着ける。

リテラルについて書かれているのは 3.10. Literals の部分だ。Java言語仕様は英語で書かれているが、現代は機械翻訳が優秀なので、機械翻訳でもある程度概要をつかむことができるのではないだろうか。また過去には公式に日本語訳がされたものが出版されたこともあるが、Java5の内容の
Java言語仕様-第3版 (2006年)が出たのが最後となっている。

Java言語におけるリテラル

Literal:
 IntegerLiteral
 FloatingPointLiteral
 BooleanLiteral
 CharacterLiteral
 StringLiteral
 NullLiteral

と列挙されていて、各項目の説明が以下に続く。

リテラルの詳細な定義まではここでは立ち入らないが、ざっくりと表にすると

種類 概要
IntegerLiteral 整数の数値 42 0xCAFEBABE 0b0010_1111
FloatingPointLiteral 浮動小数の数値 1.234
BooleanLiteral boolean型の値 true false
CharacterLiteral charの値 'A'
StringLiteral 文字列 "hoge" ""
NullLiteral null null

といった感じ。

また、 3.10. Literals に記載されていないが 15.8.2. Class Literals という項目があって、classリテラルについて記載がある。これも Java言語における「リテラル」に含めそうだ。

これは Hoge.class などといったように、クラス名に .class とつけることで java.lang.Class オブジェクトをリテラルとして表す記法。

リテラルを使わないでFizzBuzzをしてみよう!

さて、以上でJavaにおける「リテラルの禁止」の指す範囲が明確になったと思う。

プログラミングパズルが好きな人は是非、リテラルを用いないFizzBuzzに挑戦してみて欲しい。たぶん、セミコロンを使わないFizzBuzzよりは簡単じゃないかな。(参考: セミコロンレスJava はチューリング完全か? - プログラマーの脳みそ

セミコロンレスJavaもそうだが、この手のプログラミングパズルは標準APIの範囲で行うことが暗黙の了解となっている。

僕も書いてみたけども芸術点がイマイチなのでまだ満足できていない。

空気を読んだ話

一般に「リテラルを使うな」というのはプログラムコード中にマジックナンバーなどを直接記載しないということを意図したものである。なんらかの意味のあるフラグの値などは定数定義(Javaの場合はstatic finalフィールドにするかenumにすることが多いかな)して間接的に用いることを指す。

言語仕様上のリテラルを一切用いないというのは揚げ足取り的な話で、しかしここではネタとしてその制限でプログラミングを書けるか?という設問がおもしろいので取り上げている。

訂正

2021.04.19 BuzzをBullと誤っていた箇所があったので修正

IT勉強会界隈の冬の風物詩といえば、飯テロ勉強会の異名を持つブリ会議 ―― burikaigi ―― だろう。
toyama-eng.connpass.com

そもそも富山で旬の寒ブリを食べようというIT業界人の同好の士が、ブリを食べるために勉強会を催したというのが始まりである。飯テロ勉強会なのは仕方ない。
しかし、勘違いしないで頂きたいのだが、あくまでもIT勉強会なので趣旨を誤解なきよう。講師陣は今回も豪華です。セッションの内容も充実。
例年、北陸にあって先端情報に触れる貴重な機会でもあります。地方勉強会のひとつの形ですね。


プログラマーの修学旅行とも評されるburikaigiなのだが、今年ばかりは新型コロナウイルス感染症 COVID-19 の情勢を見るに、大勢で集まって宴会というわけにはいかない。
しかし、松本魚問屋様の協力によってリモート・ブリしゃぶの目途が立ち、burikaigi 2021 オンライン開催とあいなりました。

matsumoto-uodonya.co.jp

なんと、参加者限定 burikaigi スペシャル商品を用意していただきましたよ。
懇親会参加登録者には特設サイトのURLをお伝えいたします。

もちろん、自前でブリを用意してもらっても大丈夫です。
全国からリモート懇親会でブリを楽しめるイベントとなっています。



さて、ここからはリモート・ブリの紹介です。(まるで通販番組みたいだな🤔)

ブリしゃぶは水揚げ当日に血抜き処理をし、丁寧に仕上げたものを瞬間凍結!
生のブリはどうしても獲れるかどうか不確かで、かつ、保存可能な期間も限られるためブリ会議当日にちょうどおいしく配送というのは非現実的なわけです。
冷凍配送ゆえの安定供給。最近の冷凍技術は凄いですね。冷凍品とあなどるなかれ。試食してみたけども、とても美味しかったですよ!
ただ、あくまでブリしゃぶ用とのことでご承知おきください。刺身では力を十全に出し切れない……。

今回はなんと、ゆずぽん酢、タイの昆布締、ぶりジャーキー、ほたるいか塩麴漬けをセットにした飲兵衛仕様でご提供!

とはいえ、懇親会だけ参加とかはダメだよ。ちゃんと勉強しような!

f:id:Nagise:20201217201311j:plain
burikaigi用の特別セット
f:id:Nagise:20210105204911j:plain
冷蔵庫で解凍
f:id:Nagise:20210105212648j:plain
しゃぶしゃぶ

西暦1年は閏年か?

閏年(うるうどし)の話題。

 Twitterで見かけた話題で「西暦1年は閏年かどうかぱっとわからん人おる?」という些か煽り気味のツイートを見かけたのだけども、反射的に「閏年じゃないに決まってるじゃん」とぱっと答えてしまわないだろうか。本当にそうだろうか? そう単純な話なのだろうか?

 プログラミングを学んでカレンダーを扱うことを学ぶ際に置閏法についても簡単に触れられることがある。置閏法というのは閏年や閏月(太陰暦では1年が13ヵ月になるケースがあり追加の月を閏月と呼ぶ)をどのようなルールで挿入するかという話で、まさにアルゴリズムであるからプログラミングの話題と相性がいい。

置閏法

 現代の西暦の置閏法(ちじゅんほう)は

  1. 西暦を 400 で割り切れる年は閏年
  2. 上記以外で西暦を 100 で割り切れる年は平年
  3. 上記以外で西暦を 4 で割り切れる年は閏年
  4. 上記以外は平年

といった手続きで閏年(つまり2月29日がある)か平年かを判定することができる。

 2000年問題といえば西暦を2桁で扱っていたシステムの桁溢れというのが表の問題だったが、裏の問題として閏年というのもあった。2000年は400で割り切れるから閏年なわけだが、どういうわけか100で割り切れるので平年だと判定してしまうバグが見られた。単に4で割るルールだけにしておけば結果的に2000年には問題が起きなかったのに、中途半端に100年ルールだけ追加しちゃったもんだから2000年2月29日にトラブルを出すことになったシステムがいくらかあったようだ。

 やるなら半端は良くない。

その暦はいつからのものか?

 そう、半端は良くない。

 先に述べた置閏法は現代の西暦の置閏法であるが、この暦をグレゴリオ暦という。2014年にJava8が出るまでの古いカレンダークラスの場合(Java8以降のDate Time API の話は後述)、抽象クラスである java.util.Calendar に対して具象クラス java.util.GregorianCalendar が定義されていた。グレゴリアンの名前の通り、グレゴリオ暦をモデル化したものである。さあこれで、ここまで漠然と「西暦」と呼んでいたものがグレゴリオ暦という名前をもって具体化してきた。このグレゴリオ暦ローマ教皇グレゴリウス13世の名前からきている。グレゴリウス13世によってそれ以前の暦であるユリウス暦の改良が命じられ、1582年10月15日からグレゴリオ暦が行用されるようになった。

 旧暦であるユリウス暦ガイウス・ユリウス・カエサルにより紀元前45年1月1日から実施されたものである。西暦1年を考える場合、当時の暦はユリウス暦であることに注意せねばならない。

 西暦1年当時は閏年だったかどうかぱっとわからん人おる? 僕はぱっとわからなかった。

ユリウス暦の置閏法

 ユリウス暦の置閏法は

  1. 西暦を 4 で割り切れる年は閏年
  2. 上記以外は平年

とシンプルである。なんだ、ユリウス暦だとしても100の倍数の年以外は一緒じゃないか、と安心した人も多いだろう。
ただし、上記は紀元8年以降の話である。

紀元前45年にカエサルがこの暦法を導入した際に閏年は4年に1回と決められたが、直後の紀元前44年にカエサルが暗殺された後、誤って3年に1回ずつ閏日が挿入された。この誤りを修正するため、ローマ皇帝アウグストゥスは、紀元前6年から紀元後7年までの13年間にわたって、3回分(紀元前5年、紀元前1年、紀元4年)の閏年を停止した

ユリウス暦 - Wikipedia

あぶない! 西暦1年というのはユリウス暦の導入直後の混乱期で閏年が正しく運用されていなかったようだ

 なお紀元前45年から紀元8年までの間に、どの年に閏年が置かれていたのかについては諸説ある。1999年にローマ暦とエジプト暦の両方の日付が記載された紀元前24年当時の暦が発見され研究に進展があったとのこと。日本語版Wikipediaには該当論文が示されていないが、英語版Wikipediaの記述からすると ALEXANDER JONES 氏の1999年の論文 CALENDRICA II: DATE EQUATIONS FROM THE REIGN OF AUGUSTUS (PDF)が該当のようである。このカレンダーに基づくC. J. Bennett氏の2003年の研究によれば紀元前44, 41, 38, 35, 32, 29, 26, 23, 20, 17, 14, 11, 8年が3年おきの閏年でAD 4から4年おきの置閏法へと戻ったとのことである。

暦法紀年法

 西暦1年あたりの閏年の混乱を見てきたが、そもそもカエサルの時代に「西暦を4で割る」なんて考え方はされていなかったはずである。何しろ紀元前なので。

 ここで暦法紀年法について確認しておきたい。おおざっぱに言えば紀年法は年を数える方法論で、1年をどのように捉えるかの暦と分離可能なのである。例えば日本では和暦が用いられるが、これは紀年法が和暦で、暦法としてはグレゴリオ暦を用いている。なお、日本が現代の暦になったのは明治5年(西暦1872年)11月の改暦以降のことなので、これより前の時代を扱う場合は暦が連続しないので注意が必要である。このあたりの変遷は国立国会図書館江戸から明治の改暦が読みやすいだろう。


 そもそも西暦自体が525年にローマの神学者ディオニュシウス・エクシグウスによって算出されたものである。

525年、ディオニュシウスはローマ教皇ヨハネス1世の委託を受けてキリスト教の移動祝日である復活祭の暦表(復活祭暦表)を改訂する際に、当時ローマで用いられていたディオクレティアヌス紀元(ローマ皇帝ディオクレティアヌスの即位を紀元とする)に替えて、イエス・キリスト受肉(生誕年)の翌年を元年とする新たな紀元を提案した。これはディオクレティアヌスキリスト教の迫害者であり、その迫害者の名を残す事が疎まれたからである[3]。

西暦 - Wikipedia

 つまり、西暦525年から遡って元年を決めたわけである。なお、ディオニュシウスの推定したキリストの誕生年と、ナザレのイエスの実際の誕生年にはズレがあるとされており、紀元前4年頃に生まれたと考えられている。これはヘロデ大王の治世(紀元前37年 - 紀元前4年)の末期に生まれたという記述による。

 では、西暦525年よりも前の時代の紀年法ディオクレティアヌス紀元で遡ればいいかというと、ディオクレティアヌス紀元の元年も西暦284年までしか遡れない。西暦1年は遠いのである。ディオクレティアヌス紀元以前はローマ建国紀元(Ab urbe condita, AVC/AUC)が用いられていたようである。ローマ建国紀元の元年は、紀元前1世紀のローマ人ウァッロによって推定された、都市国家ローマの建国年(紀元前753年)ということである。これもまた、推定で過去に遡って元年が定められたパターンである。さらに前は執政官ふたりの名前を挙げて表したようだ。(紀年法暦法についてはここで取り上げたもの以外もたくさんあるということは付記しておく)

 さて、ここで何が言いたいかというと「西暦を4で割りきれるなら閏年」というのはたまたまであるということ。神学者ディオニュシウスの推定で定められた西暦の紀元からの数字だとたまたま4で割りきれる年が閏年の年となった。後付けである。その西暦をもとに1582年に定められたグレゴリオ暦が、閏年の調整のためにきりがよい100で割りきれる年や、400で割り切れる年を用いた、という後付け設定によるものだ、という点である。

 グレゴリオ暦の前、ユリウス暦は「西暦を4で割りきれる年を閏年をする」と定められたわけではなかった。ユリウス暦が定めらたのは紀元前のことで、初期には閏年が誤って3年おきに入れられたりその調整で閏年がない期間があったりした。それから500年余りたった頃に後付けで西暦が定められた。そうすると、たまたまユリウス暦閏年が西暦を4で割り切れる年だった。しかし単純に4年1度の閏年では春分の日がだんだんずれてきて西暦1582年にグレゴリオ暦に改められ、閏年の100年ルールと400年ルールが生じた。西暦を100で割る、400で割る、というのは後付けの西暦の切りのいい数字を用いたに過ぎない


 なお、1582年から施行されたグレゴリオ暦暦法を、1582年以前にも適用したものは proleptic Gregorian calendar と呼ばれるようだが、日本語では定訳がなく、先発グレゴリオ暦、遡及グレゴリオ暦、予測的グレゴリオ暦、予期的グレゴリオ暦などと訳されるようだ。

曜日

 400年間のうち閏年は97日あるため、日数は 365 × 400 + 97 = 146,097 日 となる。これは7で割ることができ400年で20,871週間あることになる。グレゴリオ暦は曜日も含めて400年周期の暦と言える。

 曜日の算出にはツェラーの公式が用いられることがあるが、このツェラーの公式を用いた曜日計算も1582年10月15日のグレゴリオ暦への切り替え以前に適用できないことは注意が必要である。計算することはできても、それは実際にその時代に用いられた暦ではないナニカ(先発グレゴリオ暦)である点は理解しておきたい。

 ユリウス暦グレゴリオ暦との間には約10日のズレが生じていた。切り替えに際してこの10日のズレを調整したわけだが、曜日については連続した形で適用された。つまり、ユリウス暦での 1582年10月4日(木曜日)の翌日がグレゴリオ暦 1582年10月15日(金曜日)とされた。

 また、切替えはヨーロッパ各国で一斉に行えたわけではなく、イタリア、スペイン、ポルトガルなどごく少数の国であったという。ローマ教皇による発令だったが、ローマ教皇というのはカトリックのトップであって、プロテスタントやその他のキリスト教派閥にまでその威光が通じたわけではなかったのだろう。その後、徐々に広まっていったようである。

Javaの実装

 1995年の初期のJavaから存在するjava.util.Dateクラスは、当初は日付を表す文字列をパースしたりする機能も有していたのだが、1997年のJava1.1にはこれらの機能は早々に非推奨(Deprecated)になってしまっている。そうした機能はjava.util.Calendarに分離された。java.util.Calendarはカレンダーの抽象クラスで、グレゴリオ暦に相当するのはjava.util.GregorianCalendarである。

 Java8 (2014年)が出た際にはDate and Time APIというものが追加された。(JSR 310: Date and Time API
Java8以降でプログラミングするのであれば概ね新しいDate and Time APIを使いたいところだ。

 さて java.util.GregorianCalendar だが、

歴史的に、グレゴリオ暦を最初に採用した国々では、1582年10月4日(ユリウス歴)のあとに1582年10月15日(グレゴリオ歴)が続きました。 このカレンダはこれを正確にモデル化しています。 グレゴリオ暦への切換え日の前は、GregorianCalendarではユリウス暦を実装しています。 グレゴリオ暦ユリウス暦の唯一の違いはうるう年のルールです。 ユリウス暦は4年ごとにうるう年を指定しますが、グレゴリオ暦では、400で割り切れない世紀の初年をうるう年にしません。

GregorianCalendarは、先発グレゴリオ暦およびユリウス暦を実装します。 すなわち、日付の計算では、現在のルールを無限の過去あるいは未来に向けて適用します。 このため、GregorianCalendarはすべての年について一貫した結果を生成するために使用できます。 ただし、GregorianCalendarを使用して得られた日付は、歴史的に、現代と同様のユリウス暦が採用されたAD 4年3月1日以降の日付だけが正確です。 この日付より前には、うるう年のルールは不規則に適用されており、BC 45年以前にはユリウス暦は存在さえしていませんでした。

GregorianCalendar (Java SE 11 & JDK 11 )

マジか!

Calendar c = new GregorianCalendar(1582, Calendar.OCTOBER, 4);
System.out.print(c.getTime());
c.add(Calendar.DAY_OF_MONTH, 1);
System.out.print(c.getTime());

Thu Oct 04 00:00:00 JST 1582
Fri Oct 15 00:00:00 JST 1582

 1582年10月4日(木)の翌日が1582年10月15日(金)になっている……。
 100年ルールも確認してみよう。1600年は400年ルールが適用なので、1500年と1700年で比較してみよう。

Calendar c = new GregorianCalendar(1500, Calendar.MARCH, 1);
c.add(Calendar.DAY_OF_MONTH, -1);
System.out.println(c.getTime());

c = new GregorianCalendar(1700, Calendar.MARCH, 1);
c.add(Calendar.DAY_OF_MONTH, -1);
System.out.println(c.getTime());

Sat Feb 29 00:00:00 JST 1500
Sun Feb 28 00:00:00 JST 1700

 ユリウス暦である1500年3月1日の前日は2月29日となっている。一方、グレゴリオ暦の1700年は100年ルールが適用されて3月1日の前日は2月28日だ。
 先発グレゴリオ暦のつもりでjava.util.GregorianCalendarを使うと、予期しない動きをするかもしれない。

 なお、純粋に先発グレゴリオ暦として用いたい場合は setGregorianChange(java.util.Date) メソッドでグレゴリオ暦への切り替え日をより過去にすることで対処することができる。

ISO 8601

 ISO 8601 で日付と時刻の表記に関する国際規格が定められている。

 端的に言えばこのISO 8601は先発グレゴリオ暦で、1582年10月15日以前にも適用される。ただし、0000年から1582年の範囲は、事前に通信の送信側と受信側との間での合意がある場合にのみ使うことができるとされている。

 このISO 8601に相当するのが IsoChronology である。Java8 以降の Date and Time API での標準はこのIsoChronologyとなっている。

 java.time.chronoパッケージの解説によればIsoChronologyの他に

が標準で実装が提供されている。これらの暦にまで踏み込むと話が長くなりすぎるので打切り。太陽太陰暦での閏月などについては興味のある人は調べてみると面白いだろう。

結論

 結果的に言えば西暦1年は閏年ではなく平年である。

 しかし、その導出過程は「西暦を4で割り切れないから」ではなく、「ユリウス暦は初期には閏年が誤って3年おきに入れられ、その調整で閏年がない期間があり、西暦1年はその期間に該当するから」というのがより正確な回答であろう。

 なんらかのアルゴリズムを学んだ時に、なぜそのアルゴリズムなのか、その適用範囲はどこまでなのか、現実に人がそのように使っているのか? といった部分にも興味をもって目を向けてみると少し世界がひろがって少し面白い世界が知れるかもしれない。

OSSの通らない自動テストを追いかけた話

備忘録。

MyBatis Migrationsソースコード を落としてきて自動テストを叩いてみたけども通らないテストがあったんですよ。(masterブランチ commit 29945670f2937e3c9f107d9f59e6b03d7e58926e 2020-10-19版)

java.lang.AssertionError:
Expected: (an instance of java.io.FileNotFoundException and exception with message is "/tmp/NoSuchFile.sql (No such file or directory)")
but: exception with message is "/tmp/NoSuchFile.sql (No such file or directory)" message was "\tmp\NoSuchFile.sql (指定されたパスが見つかりません。)"
Stacktrace was: java.io.FileNotFoundException: \tmp\NoSuchFile.sql (指定されたパスが見つかりません。)
at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.(FileInputStream.java:138)
at java.io.FileReader.(FileReader.java:72)
at org.apache.ibatis.migration.commands.BaseCommand.copyTemplate(BaseCommand.java:173)
at org.apache.ibatis.migration.commands.BaseCommandTest.testNonexistentFile(BaseCommandTest.java:61)
(後略)

該当のテストコードはこちら

  @Test
  public void testNonexistentFile() throws Exception {
    String srcPath = "/tmp/NoSuchFile.sql";
    expectedException.expect(FileNotFoundException.class);
    expectedException.expectMessage(is(srcPath + " (No such file or directory)"));

    File dest = File.createTempFile("Out", ".sql");
    try {
      BaseCommand.copyTemplate(new File(srcPath), dest, null);
    } finally {
      dest.delete();
    }
  }

expectedException には @Rule がついてて、要するに実行してエラーが出たら、エラーメッセージが想定通りかを検証している自動テストなわけです。
スタックトレースから "/tmp/NoSuchFile.sql (No such file or directory)" が期待されるメッセージだったけど "\tmp\NoSuchFile.sql (指定されたパスが見つかりません。)" というメッセージだったから合致しなかったよ、ということが読み取れます(基本)

試したこと

JUnitの実行時のJavaVMのオプションに -Duser.language=en を設定する。
これはシステム・プロパティの値を設定するもの。
詳しくは java コマンドのリファレンス で -D のところを見てください。
システム・プロパティ "user.language" でデフォルトの言語ロケールを変更することができます。

しかし、これを設定してもテストは通らず。

自動テストに手を加えて、直前でプログラムでシステムプロパティを変更してみます。

System.setProperty("user.language", "en");

しかし、これでも効果なし。なぜ?

原因探索

言語の設定をしてもエラーメッセージが変わらないので FileNotFoundException が作られるところでどういうコードになっているのか探すことに。

at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.(FileInputStream.java:138)

トレースを辿ると FileInputStream の open0 メソッドで Native Method になってしまいました。OSに合わせたC言語のネイティブコードになってるわけですね。

でも大丈夫! JavaGPLオープンソースなのでネイティブメソッドもばっちり公開されています。
Javaのコードは元々Mercurialというバージョン管理ツールで管理・公開されていたんですが、世の中のシェアを鑑みて最近Gitに変わりました。(JEP357)


さて、OpenJDKのリポジトリのソースを見てみましょう。
今回のターゲットは共通部は src/java.base/share/native あたりに、OS固有のものは src/java.base/【OS名】/native あたりにあります。

まずは FileInputStream.c から

JNIEXPORT void JNICALL
Java_java_io_FileInputStream_open0(JNIEnv *env, jobject this, jstring path) {
    fileOpen(env, this, path, fis_fd, O_RDONLY);
}

さらに辿って io_util_md.c の fileOpen()

void
fileOpen(JNIEnv *env, jobject this, jstring path, jfieldID fid, int flags)
{
    FD h = winFileHandleOpen(env, path, flags);
    if (h >= 0) {
        jobject fdobj;
        jboolean append;
        fdobj = (*env)->GetObjectField(env, this, fid);
        if (fdobj != NULL) {
            // Set FD
            (*env)->SetLongField(env, fdobj, IO_handle_fdID, h);
            append = (flags & O_APPEND) == 0 ? JNI_FALSE : JNI_TRUE;
            (*env)->SetBooleanField(env, fdobj, IO_append_fdID, append);
        }
    }
}

同ファイル内のwinFileHandleOpen()

FD winFileHandleOpen(JNIEnv *env, jstring path, int flags)
{
/* 中略 */
    if (h == INVALID_HANDLE_VALUE) {
        throwFileNotFoundException(env, path);
        return -1;
    }
    return (jlong) h;
}

さらに io_util.c の throwFileNotFoundException()

void
throwFileNotFoundException(JNIEnv *env, jstring path)
{
    char buf[256];
    size_t n;
    jobject x;
    jstring why = NULL;

    n = getLastErrorString(buf, sizeof(buf));
    if (n > 0) {
        why = JNU_NewStringPlatform(env, buf);
        CHECK_NULL(why);
    }
    x = JNU_NewObjectByName(env,
                            "java/io/FileNotFoundException",
                            "(Ljava/lang/String;Ljava/lang/String;)V",
                            path, why);
    if (x != NULL) {
        (*env)->Throw(env, x);
    }
}

さらに jni_util_md.c の getLastErrorString()

JNIEXPORT size_t JNICALL
getLastErrorString(char *buf, size_t len) {

    DWORD errval;

    if ((errval = GetLastError()) != 0) {
        // DOS error
        size_t n = (size_t)FormatMessage(
                FORMAT_MESSAGE_FROM_SYSTEM|FORMAT_MESSAGE_IGNORE_INSERTS,
                NULL,
                errval,
                0,
                buf,
                (DWORD)len,
                NULL);
        if (n > 3) {
            // Drop final '.', CR, LF
            if (buf[n - 1] == '\n') n--;
            if (buf[n - 1] == '\r') n--;
            if (buf[n - 1] == '.') n--;
            buf[n] = '\0';
        }
        return n;
    }

    // C runtime error that has no corresponding DOS error code
    if (errno == 0 || len < 1) return 0;
    return strerror_s(buf, len, errno);
}

ここで FormatMessage というのが Win32 APIFormatMessage ということのようです。

DWORD FormatMessage(
DWORD dwFlags,
LPCVOID lpSource,
DWORD dwMessageId,
DWORD dwLanguageId,
LPTSTR lpBuffer,
DWORD nSize,
va_list *Arguments
);

というパラメータになっていて dwLanguageId に 0 が渡されていますね。

If you pass in zero, FormatMessage looks for a message for LANGIDs in the following order:

1. Language neutral
2. Thread LANGID, based on the thread's locale value
3. User default LANGID, based on the user's default locale value
4. System default LANGID, based on the system default locale value
5. US English

ここでメッセージが日本語にされていたようです。OSレベルのメッセージだったからJavaシステムプロパティで言語設定を変えても影響されず、日本語のメッセージが表示された、というわけでした。

結論

こんな自動テストは消してしまおう……!

ファイルパスの違いは JUnit の OS指定の @EnabledOnOs アノテーションで逃げることができるのですが、今回はメッセージの部分まで含んでいるのでこのアノテーションでの対処では不十分です。

OS に依存するメッセージを固定でテストするというのは過ぎた制約で、テストとしてはおよそやりすぎの類のものです。
テストコードに手を加えずに通したいなら言語設定が英語のmac使うといいんじゃないかな。

Spring boot で作るAPIと React のビルドを共存させたい

Spring boot では Webシステムをmain起動のスタンドアロンJavaプログラムのように起動して試せる点が便利である。ところでこうしたサーバーサイドのWeb APIを作るようなケースで、Reactのようなコンパイルを伴うWebクライアントと連携して動作するようなものを開発するとき、どのようなプロジェクトのディレクトリ構成でどのようなビルド、どのような実行をすると便利にやれるのだろうか?

IDEの有償プラグインがこうした開発を統合してくれるのかもしれないが(試用して回ったわけではないのでよくわからない)ここではギョームで普通に用いられているEclpise IDEVisual Studio Codeの組み合わせで考えてみたい。(本稿ではIDEは主題ではないので、任意のIDEで検討してもらいたい)

Spring の フォルダ

SpringのQuick Start に従い、デモプロジェクトを作成し、Eclipse側でMavenプロジェクトとしてインポートすると以下のような構成になっている。

f:id:Nagise:20200627145159p:plain
Spring boot の構成

ここで、DemoApplicationを実行するとSpring bootが内臓のTomcatを起動させ、 src/main/webapp 以下(図のオレンジの矢印の部分)が http://localhost:8080/ 以下のパスでアクセスできるようになる。
加えて、@GetMapping("/hello") のアノテーション定義に則って http://localhost:8080/hello にアクセスすると、そのアノテーションがついているhelloメソッドを呼び出し、その戻り値をhttpのレスポンスとして返すように動く。

React の フォルダ

create-react-app を使ってシンプルなReactのサンプルを作成すると、以下のような構成になっている。

f:id:Nagise:20200627145306p:plain
React の構成

npm run build でコンパイルすると、html や JavaScript が動く形で build ディレクトリに作成される(図のオレンジの矢印部分)。Webアプリケーションとしてサーバーに配置するのはこのbuild部分ということになる。

双方の都合

サーバーサイドのWeb APIと、コンパイルして生成されるWeb クライアントを複合して動かす場合に、上記のような2系統のビルドをどうにか両立させないといけない。

最終的にTomcatのようなWebサーバーに配備する場合であれば、ビルドスクリプトによってReactのビルドを動かし、生成物を Spring boot 側の webapp 以下の配備予定パスに複製、そののち、 Spring boot 側をwarファイルにパッケージングしてwarファイルをWebサーバーに配備、という手順を踏むことになるのだろう。しかし、開発段階からこれをやるのは面倒だし、開発時にはクライアント側もサーバー側も双方ともいじりながら開発することもあるから、両開発環境でビルドがしたい。(逆に、完全に分業がされているならば、この2系統ビルドで頭を悩まさなくていい)

この2系統ビルドとその実行を便利にしたい、というのが今回のテーマである。

方法はいろいろあるのだろうが、今回は Spring boot をハックして Tomcat の Resources Component機能を利用してデバッグ環境のTomcatがSpring boot の src/main/webapp ディレクトリと、Reactのbuildディレクトリの双方を参照するように設定してみたい。

Tomcatによるリソースの読み込み

あまり知られていないかもしれないが、Tomcatにはファイルリソースを読み込むにあたって、ファイルを読み替えることができる。

日本語で解説してくれている記事は少ないが、 webapps配下でないディレクトリにリソースを置く - DukeLab では例が挙げられている。
Tomcatがリソースを読み込む際に PreResources の設定があれば、先にそちらを読みに行き、そこにリソースがなければ通常のwebappフォルダ、そこにもなければ PostResources の設定の場所を読みに行く。これによって、設定ファイルなどをwarファイルの外に分離したりすることができる。

TomcatThe Resources Component というドキュメントに詳細が記載されている。
この機能を用いる際には注意点がある。Javaプログラム中で設定ファイル等にアクセスする際には java.io.Fileクラスなどを用いてはいけない。 javax.servlet.ServletContext の getResource() などのメソッドを用いてファイルリソースを取得することを徹底しなくてはならない。HTMLなどの静的ファイルにだけ用いる分にはこのあたりはあまり気にする必要はない。

この Resources Component機能の設定は古典的には Tomcat本体のserver.xmlを編集することになる。このserver.xmlは、Tomcatがwarファイルをどのように配備するかといった設定を記載するファイル。しかし、warファイル側でこのデフォルト設定みたいなのを書いておきたいみたいな要件があり、 warファイルの /META-INF/context.xml を用意しておくと、配備時にこの設定が生かされるようになった。

これを Spring boot の内蔵Tomcatに対してどう設定するかという話なのだが、/META-INF/context.xml を用意しても内蔵Tomcatには効かない。なにか方法はないかと探していたら
SpringBootで組み込みTomcatの設定 という記事が目についた。Spring boot では TomcatServletWebServerFactory内蔵Tomcatの設定を行えるらしい

しかし、APIドキュメントを探してみてもResources Componentの設定は行えなさそうだ。さて、どうする……?

WebResourceRoot

Spring boot の TomcatServletWebServerFactory のソースコードを読み込んでいると、org.apache.catalina.WebResourceRoot というTomcatのクラスの createWebResourceSet というメソッドを呼んでいる箇所が気になった。

	URL url = new URL(resource);
	String path = "/META-INF/resources";
	this.context.getResources().createWebResourceSet(ResourceSetType.RESOURCE_JAR, "/", url, path);

前後を読むとこれが直接の該当機能というわけではないのだが、この createWebResourceSet メソッドこそが求める Tomcat の Resources Component 機能であるようだ。WebResourceRoot は interface なので Tomcat のソースを辿り実装クラス StandardRoot を見つける。このソースを見るとこれが探していた機能であることを確信する。これを Spring boot で Tomcat を設定する TomcatServletWebServerFactory でうまく呼び出せれば……?

import org.apache.catalina.Container;
import org.apache.catalina.Host;
import org.apache.catalina.WebResourceRoot;
import org.apache.catalina.WebResourceRoot.ResourceSetType;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.startup.Tomcat;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.embedded.tomcat.TomcatWebServer;
import org.springframework.boot.web.server.WebServer;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TomcatConfigration {
    @Bean
    public ServletWebServerFactory serverSettings() {
        TomcatServletWebServerFactory factory = new MyTomcatServletWebServerFactory();
        return factory;
    }

    static class MyTomcatServletWebServerFactory extends TomcatServletWebServerFactory {
    	@Override
    	public WebServer getWebServer(ServletContextInitializer... initializers) {
    		TomcatWebServer ret = (TomcatWebServer) super.getWebServer(initializers);

    		Tomcat tomcat = ret.getTomcat();
    		Host host = tomcat.getHost();
    		Container[] findChildren = host.findChildren();
    		StandardContext context = (StandardContext) findChildren[0];
    		WebResourceRoot webResourceRoot = context.getResources();
    		webResourceRoot.createWebResourceSet(ResourceSetType.PRE,
    				"/", "C:\\work\\react\\demo\\react-demo\\build", null,  "/");
    		return ret;
    	}
    }
}

単純にWebResourceRootを得て素直にcreateWebResourceSet()を呼び出せなかったため工夫する必要があった。

TomcatServletWebServerFactory を継承したクラスをつくり、getWebServer()をオーバーライドしてsuper.getWebServer(initializers)で作成した TomcatWebServer オブジェクトから WebResourceRoot を引っ張り出し、createWebResourceSet() に react側のbuildディレクトリを指定するようにしてみた。

f:id:Nagise:20200627151041p:plain
Spring boot と React の共存


このように、Spring boot の動的なAPIと、React が Spring boot の内臓Tomcatによって同時させることができた。
URL がともに localhost:8080 となっている点に注目いただきたい。

まとめ

  • Spring boot の内蔵 Tomcat に設定を加えることで、外部のファイルリソースを組み合わせることができた
  • Reactのような別系統のビルドが必要な環境と組み合わせて開発する際に便利

よりよい工夫などあれば、トラックバックして頂けると嬉しい。

追記

Reactに限って言えば、React側のサーバーにproxyを指定してやることで、Spring boot側のAPIを同一ドメインで呼び出せるようにすることができるようだ。

React開発時には、APIサーバーとReactアプリサーバーを別にして、プロキシを使うというベスト・プラクティス

本来静的コンテンツであるところの HTML + JavaScript の動作を確認するためにWebサーバーを立てているわけだが(npm startで起動させているアレである)そこのProxyを通して本来の動的WebサーバーであるところのTomcatを呼び出している。僕がサーバーサイドエンジニアだからか、この方式は少し不思議な構成という気がする。


Vue.js でも同様のアプローチをとっているようだ。

[Vue]devServerでフロント開発中、別ポートで起動しているバックサーバーにリクエストを送る方法 - Qiita


本記事のアプローチはこれらとは逆のような感じになるが、React や Vue.js に限らず、汎用に用いることができる方式であるし、なんとなれば、React と Vue.js の混在にでも対応できる。(そんなことをしたいわけでもないが)

また、proxyがどのような通信方式をサポートしているかという点にも不安があって、例えば WebSocket を持ちいたAPIサーバーなどに対応できているのか?などなど、サーバーアプリ屋としてはサーバーをメインに据えたい気持ちがいろいろとある。

しかし、視点を変えて、分業体制とかでクライアントサイドを主体としている開発者側からはproxy方式の方が便利なのかもしれない。