株式会社ヘンリー エンジニアブログ

株式会社ヘンリーのエンジニアが技術情報を発信します

JVM勉強会(開発編)を開催しました

こんにちは、SREの戸田です。本日はJVM勉強会(運用編)に続けて開催したJVM勉強会(開発編)の一部を公開します。

図1 勉強会はやっぱりGoogle Meetでオンライン開催しました

システムプロパティ

システムプロパティは環境変数のように、プログラムの挙動を変えるために利用することが多いです。例えばOpenJDKそのものでも Integer.valueOf() で値をどの程度キャッシュするか*1を設定するためにシステムプロパティを使っています

他にも user.language あたりはよく知られていますし、標準で提供されるシステムプロパティも多数あります。しかし製品コードから直接参照することは基本ないと思っていて、 File.pathSeparator などの提供されたAPIを使うことが望ましいでしょう。またシステムプロパティは動的に変更することも可能ですが、システムプロパティを読み込む側がそれを想定しているかは確認したほうが良いでしょう。

文字コード

JVM自体はOS標準の文字コードを用いることになっていました。すなわちここは特に抽象化されておらず、プログラマが意識する必要がありました。 しかしJava 18からUTF-8が標準になるので、動作環境について気にする必要性がひとつ減ります。

逆に言えば、暗黙的にOS標準の文字コードを期待しているプログラムがあるならJava 18以降では注意が必要になります。医療機関向けシステムを扱っている弊社の場合だと、仕様としてShift_JISの利用を求めている書式がレセ電コード情報ファイルをはじめとして多数存在していることから、明示的に文字コードを指定する書き方を徹底することが必要です。

改行コード

JVM自体は改行コードの扱いを抽象化しません。このためプログラマがCR, LF, CRLFのいずれを使うか決定する必要があります。

とはいえOS標準の改行コードを使う場合は PrintWriter, BufferedReader, Scanner といった標準ライブラリが提供する抽象化をもって対応すれば充分で、わざわざ System.lineSeparator() などを利用して改行コードを明示的に扱うことは不要でしょう。

なお書式を扱うメソッドでは、Formaterが提供する %n を使うことでOS標準の改行コードを参照することもできます。

マルチスレッドプログラミング

JVM/Javaはマルチスレッドプログラミングに向いているという話から入り、それでも考えることが結構あるよねという話で盛り上がりました。

特に InterruptedException の扱いが複雑という点は、電車本(Java並行処理プログラミング)に詳しく説明されてるもののかなり古いため、最近の良い本を知りたいという声が複数出ました。おすすめの書籍がある読者の方はぜひ教えていただきたいです。

よくある落とし穴

JVMや標準ライブラリの挙動を知らないとハマる落とし穴がけっこうあり、以下に挙げるものを含めいくつか紹介しました。

JVMとタッグを組んで顧客価値にこだわるエンジニアを探しています

運用編と開発編の2回に分けて、JVMについて勉強会を開催しました。こうした機会を通してJVMの落とし穴を避けて性能を引き出すプログラムが書けるようになったり、システム運用のペインに刺さる監視が行えるようになると嬉しいです。

弊社では医療業界の業務改善のため、医療機関のERPと呼ぶべき電子カルテやレセプトコンピュータをSaaSとして開発・提供しています。JVM大好きな方も、TypeScriptどんと来いな方も求めておりますので、関心をお持ちの方はぜひ採用ページをご覧いただければと思います。よろしくお願いいたします。

jobs.henry-app.jp

*1:この話をしたらPythonにも似たようなキャッシュ機構があるという話が聞けて面白かったです、勉強会は話す側にもたくさん学びの機会があって良いですね

オブザーバビリティにはお金がかかる

tl;dr

オブザーバビリティにはあなたの直感よりもお金がかかるかもしれない。でもそれはアジリティを上げるために必要なコストである。同時にオブザーバビリティ関連ベンダーには、それらをリーズナブルに提供してもらうことを期待します。

オブザーバビリティ・エンジニアリング輪読会

8月からVPoEになりました。id:Songmuです。

社内の勉強会で輪読形式でオブザーバービリティ・エンジニアリングを読んでいます。毎週30分、参加者の中から発表者を割り当て、1~2章を読み進めるスタイルです。

ちなみに、ヘンリーではActive Book Dialogue(ADB)というフォーマットも取り入れて輪読会が運営されています。社内で同時並行で数本走っており、先日、CEOの逆瀬川が書いたソフトウェア見積もりに関する輪読会も同様の形式で実施しています。

発表者は、事前に社内のNotionにその章のアウトラインや論点などをまとめてから会に臨みます。私も何度か担当しましたが、発表者は担当章を読み込むので理解が深まります。私は読書メモを取る習慣はなかったのですが、事前にNotion上に要点をまとめることで理解が全然違うことに驚きました。ADB形式での輪読会、オススメです。

このオブザーバビリティ・エンジニアリングがそろそろ読み終わりそうなので、所感をまとめてみます。

オブザーバービリティ・エンジニアリングとは

この本で述べられていることは単純で、構造化イベントがオブザーバビリティの肝となる構成要素で、それを構造化ログ、特にOpenTelemetryのような標準的なテレメトリ手法で逐一記録しておけば、後から任意の集計や調査、デバッグがやりやすくなる、というものです。

従来のメトリクスは決め打ちの次元で集計された情報量が落ちた値であり、既知の未知に対する調査方法としては有効ですが、未知の未知には弱い。構造化イベントをすべて記録しておくことで、問題が発生した後からでも、過去に対して任意の次元で集計したり、あるリクエストに付随するサービス間の呼び出しをトレースしたり、関連イベントをクエリしたりできるようになります。

オブザーバビリティがあれば、現代的な複雑なアプリケーションに対してもプロアクティブなデバッグが可能になり、素早くフィードバックサイクルを回せ、アジャイルな価値提供が可能になる、ということです。

オブザーバビリティの3本柱という言葉には触れられているものの強く支持はされていません。あくまでイベントが肝で、それを構造化ログとして記録しておけば、あとからトレースもメトリクス集計もクエリもできるよ、という単純明快な主張で分かりやすく、同意できます。

サービス開発者の立場としてもOpenTelemetryの手法をフォローしておけば、多くのオブザーバビリティツール・サービスとの接続もやりやすくなるため、これが業界標準としてもっと広く使われて、エコシステムがより成熟することも期待したいと感じました。

理想的だがお金はかかる

しかし、同時に思ってしまうこととしては「それってカネがかかるよな」ということです。実際、本書には以下のように書かれています。

オブザーバビリティツールは、テレメトリデータのカーディナリティやディメンションを制限することなく、発生するすべてのイベントに関する豊富なテレメトリーを収集し、任意のリクエストに対するコンテキストをすべて伝え、この先のどこかの時点で使用できるようにするために保存する、このようなことを開発者に推奨しています。 p.16

実現のためには結局、逐一構造化イベントログをどこかしらに記録し保存する必要があるということです。

ログと聞くと費用面で頭を痛めているエンジニアの方も多いのではないでしょうか。ログやそれを保管するDWH、その他オブザーバビリティ関連費用は年々増加傾向にあり、企業によってはそれがクラウドコスト全体の数割にも及ぶ、などという話も聞きます。

それらは本当に高いのか

しかし、それは本当に高いのでしょうか。良く考えてみると、高頻度の書き込みに耐え、大量のデータを効率よく集計・クエリ可能な形で保管するのは大変で、コストがかかるに決まっています。それでもそれらの情報を記録しておくことがビジネス価値につながる、ということが本書の主張なのでした。

  • 「たかがログになんでこんなにお金をかけなければならないのか」
  • 「直接的なコンピューティングリソースにお金がかかるのは仕方がないが、付随的なオブザーバビリティ関連費用が高額になるのは腑に落ちない」

そんなことを思うかもしれません。しかし、それは私達エンジニアが忌み嫌っているはずの、運用・運営軽視の態度です。

例えば、昔はCIサーバーにお金をかける発想は乏しかったですが、今はそこにコストをかけるのは当たり前になり、そこに力をかけられるサービスのほうが生き残れるようになりました。

また、昔は新規サービス開発が終わったらチームを解体、縮小して運用するのが多く見られましたが、今では運用・運営に力を入れて継続的にサービスを改善していくことが当たり前になりました。

こういった比重の変化がオブザーバビリティでも進行しており、マインドチェンジが必要です。少なくともエンジニアが「思ったよりお金がかかるな」などと思ってしまうと、経営者はそこに踏み込んで投資はしてくれません。それがビジネス成長のために必要なコストで、更に言うと、そこをおろそかにしていると変化が遅くなり生き残れないリスクがある、ということを認識し、説明可能になる必要があるでしょう。

システム運用的なコストセンターマインドだと、とかく運用関連費用を削りたがってしまうことがあります。もちろんコスト最適化は大事ですが、必要な部分にコストをかける前に削り始めるのは良くありません。

まあでも高いよね

とは言え、オブザーバビリティ関連費用が高くつくのは否めません。その辺りについて本書は後半歯切れが悪くなるように感じられて味わい深かった。そこには大きく以下の2つの理由があるように感じました。

  • オブザーバビリティが本来的にコストが掛かるものであるということ
  • OpenTelemetry周りがまだ未成熟であること

コスト面では、前半では「情報は全て残せ」みたいに強気に言っていたのに、17章でデータサンプリングについてまるまる1章割くなどしています。

また、15章の「作るか、それとも買うか」の章も、筆者がオブザーバビリティベンダー所属であるから公平さを期して書いているのも分かるのですが、業界が未成熟であるがゆえの歯切れの悪さも感じてしましました。

16章の「効率的なデータストア」の章では、オブザーバビリティサービスを提供しているHoneycomb社内で実際にどのように、データを保持しているのかについて書かれています。これはエンジニアとしては読み応えがあり興味深い内容ですが、それと同時に、これは複雑で自前で運用はしたくないよなぁとも感じました。

やはり、多くのテレメトリ情報を扱う基盤は、自前運用が大変であることが想像できるので、どこかのベンダーに任せたい。それに加えて、ロックインされすぎないように、ベンダー間の相互運用性も求めたい。

相互運用性についてはOpenTelemetryを計装しておけば、多くのベンダーに接続できる流れになりつつあり、そのエコシステムのさらなる成熟を望みます。しかし、そのベンダーの選択肢については「ここに任せておけば安心」という有力ないくつかのサービスが存在するかと言うと、まだ足並みが揃っていないように感じます。

このあたりは、複数の有力なベンダーが同じ土俵の上で価格競争され、よりリーズナブルにサービスが提供される流れになることを期待しています。

ヘンリー社では

社内ではこのあたりは鋭意整備中ですが、Sentryで発番したtrace IDをTypeScriptやKotlinで書かれたサービスに引き回し、OpenTelemetryで計装するところまでは実現できています。それらとGoogle Cloud Logging, Cloud Trace, BigQueryなどを組み合わせて可視化、トレース、クエリ等を実現しています。

可能ならGoogle Cloudの機能で一通りまかないたいと思っているのですが、煩雑になってきているので、他社がどうしているかも知りたいところです。また、サードパーティのソリューションも検討しても良いとは考え始めています。このあたりのノウハウが他の会社の技術ブログ等で書かれると嬉しいです。

まとめ

ヘンリー社内での輪読会取り組み及び、オブザーバビリティ・エンジニアリングについて取り上げました。

ヘンリーはまだ数十人のスタートアップであり、輪読会やオブザーバビリティの取り組みにおいても、職種をまたがって実施しています。輪読会にはCEOや営業、CXメンバーが参加していますし、オブザーバビリティの取り組みではアプリケーションエンジニアが計装を実施しました。

小さいチームでクロスファンクショナルに活気を持って開発を進めていますし、お客様も増えてきていて拡大期に入ろうとしていて、もっと仲間を増やしたいと思っています。SRE、Webエンジニア、各職種を募集中ですので、興味のある方はぜひご連絡ください。

JVM勉強会(運用編)を開催しました

こんにちは、SREの戸田です。本日は社内で開催したJVM勉強会(運用編)の一部を公開します。

JVM、使っていますか?弊社ではサーバサイドKotlinが活躍しているので、もちろん日常的にJVMが稼働しています。このためサービス運用の一貫で必要になる知識や関連ツールなどをSREないしプロダクトチームに共有することを目的として、この勉強会を開催しました。

図1 勉強会はGoogle Meetでオンライン開催しました

パフォーマンス・チューニング

サービスを開発していると、この処理をもっと高速化したい!ランニングコストを抑えてユーザ体験の向上に投資したい!というというシーンには多く遭遇しますよね。こうしたユーザが増えてサービスに負荷がかかるようになったことで生じた課題に対して迅速に打ち手が取れることは、とても重要です。

しかし焦ってはいけません。「このコードはめっちゃループしてるし遅そう!」「あの機能を入れたら手触り悪くなったから、あの機能が怪しい!」という勘で改善を始めてしまうと、本当に改善されたのかの検証が難しいことに加え、問題の根っこを見つけられない可能性も出てきます。まずは、計測から始めましょう。

scrapbox.io

今回は問題となった処理をメソッドとして切り出せることを期待して、Java Microbenchmark Harness (JMH)を紹介しました。またその利用事例として inputStream.readBytes().toString(Charsets.UTF_8)inputStream.bufferedReader(Charsets.UTF_8).readText() に変えることで高速化することの確認を紹介しました:

Benchmark                   Mode  Cnt       Score      Error  Units
MyBenchmark.buffered128k   thrpt   25  226168.261 ± 3085.163  ops/s
MyBenchmark.buffered512k   thrpt   25  226123.470 ± 1595.982  ops/s
MyBenchmark.readBytes128k  thrpt   25    5105.985 ±   86.458  ops/s
MyBenchmark.readBytes512k  thrpt   25    1258.793 ±   49.706  ops/s

また問題が特定できていない場合に使える手段として、JDK Misson Control(JMC)などで作成できるJDK Flight Recorder(JFR)ファイルの作成を紹介しました。JFRファイルはJVMの状態をひろく、また時間軸に沿って観察したデータを残せるため、どこに問題があるかわからない状態でも活用しやすい特徴があります。

図2 JFRファイルをJMCで見ればメモリの利用状況も可視化される

そこからヒープダンプを取得したりマイクロベンチマークにつなげることで、より問題を掘り下げて調査する事ができると考えています。

起動高速化

Cloud RunでKotlinアプリケーションを動かす際、サービスの起動速度は重要なポイントになります。コンテナの起動が遅いとデプロイやスケールアウトに影響があり、最悪の場合はすぐにお客様に届けたい修正がなかなか届かない原因になってしまいます。

この課題は多くのユーザで共有されているようで、Cloud Runの公式ドキュメントではAppCDSによる高速化を紹介しています。ただこの方法だとデプロイの前処理が増えるので、スケールアウトはともかく修正を迅速に届けたい場合にはあまり有効ではないかもしれません:

cloud.google.com

今回の勉強会ではこれに加えて、CRaCjlink、native-imageについて紹介しました。またそれぞれの特徴を踏まえて、いまの自社サービスに適切と思われるアプローチについて議論しました。

図3 起動高速化に加えてコードの動作高速化についても実例を交えて議論しました

質疑応答

最後に質疑応答の時間を取りました。

まず -Xms-XX:MinRAMPercentage のような似た設定ではどちらを使えば良いのか?という質問がありました。答えは -XX:MinRAMPercentage です。JDKにはエルゴノミクスという考え方があり、ざっくりと要求を伝えておけば自身を動的に最適化してくれます。そのためヒープのサイズを -Xms-Xmx でバイト単位で指定するのではなく、このくらいまでメモリを使っていいよとざっくり伝えて細かいチューニングを任せるようにします。

docs.cloudbees.com

またStacktraceを取得するのはコストが高いのではという質問もありました。実はJava 9でStackWalkerが実装されたことで、Stacktraceを取得するコストは安くなっています。その効果は高く、例えばlog4j2ではJava9以降ではこの新しいAPIを使ってメソッド呼び出し元を特定しています。

JVMと仲良くなってKotlinをもっと活用したい

今回のJVM勉強会(運用編)は良い盛り上がりを見せました。Kotlinを活用して顧客に価値提供をするためにも、こうした基盤部分の理解を深めることは大切です。今後もチーム一丸となって学ぶため、運用編に続き開発編も開催したいと考えています。

なおKotlinに関する活動については以下の記事でも紹介していますので、あわせてご覧いただけますと幸いです。

経営層が知るべき、目標と見積りの話について

img_tagert

こんにちは。ヘンリーCEOの逆瀬川です。

今日は、ソフトウェア見積りという本を社内輪読会で読んでいるのですが、この本のお陰で、目標と見積りに関するコミュニケーションが劇的に改善したお話をします。

ソフトウェア見積りはとても良い本なので、他にも紹介したいことがありますので、それは別の機会に。

皆さんは目標や見積りはどのような意味で使っていますか? デジタル大辞泉によると、目標とは、「 行動を進めるにあたって、実現・達成をめざす水準。」と定義されてます。

皆さんがイメージしていた内容ではないでしょうか?私たちもあまり認識がずれるとは思ってなかったので、社内であまり定義せずに使っていた結果、下記のような問題が発生していました。

  • 目標と必達目標が生まれ、どちらの話をしているのかがわからない
  • 目標の期日と品質がいつの間にか必達水準に変わってしまうため、見積りをそのまま目標としてしまう
  • 見積りのコミュニケーションをしていたはずが、いつのまにか目標にすり替わってしまう
  • 元々出したい期日が目標として決まっており、その目標を達成前提で計画する。(しかも、その計画が非現実的)
  • 開発スケジュールが遅れに遅れ、見積り不要論が発生する
  • 開発スケジュールが遅れに遅れ、逆に同じような見積りを何回もする

などなど

結果、目標に関してミスコミュニケーションが多発しており、目標とはどうあるべきだという議論をしつつ、結論が出ないままフラストレーションが溜まる日々でした。 そこで、ソフトウェア見積りという素晴らしい本に出会います。

“ターゲット“と“コミットメント“と"見積り"と

ソフトウェア見積りに、見積り、ターゲット(目標)、コミットメントについての説明がありますが、まさに求めていたものでした。 しかも、1章に求めていたものが!!

- 見積り : プロジェクトにかかる期間やコストを予測
- ターゲット : 実現したいビジネス上の目標を明文化したもの
- コミットメント : 定義された機能を、特定の品質レベルを確保しながら期日までに納品するという約束

「この日までに終わらせたい」が見積りを歪めていた

スタートアップたるもの、なるべく早く開発したいし、Over達成したいものです。ましてや、私は楽天出身です。

いつの間にか、見積りを出す時または見積りを受け取る時に、目標を伝えていました。

みなさんもこういう経験あるのではないしょうか?

"見積り"と"ターゲット"の分類が全てを救う

“ターゲット“と“コミットメント“と"見積り"を分けて、コミュニケーション取るようになったため、社内のコミュニケーションがだいぶ改善されています。

「XXXの機能ですが、見積りとしては3週間程度で終わりますが、ターゲットとしては、2週間で終わらせたいと思っています」

「年内のロードマップですが、見積りとしては、1.5ヶ月ほどはみ出る可能性が高いです。XXとYYが特にリスクが高いので、それを早めに解消して、再度見積りとターゲットを決めましょう!」

などなど、建設的な意見が生まれるようになりました!

ソフトウェア見積りは、今年一番読んで良かった本といっても過言でもないので、ぜひ、プロジェクト進行や見積りなどで困っている方は読んでみてください。

経営者の皆さん、「それいつ終わるの?」って聞いてないですか?

最後に、自戒も込めて。 経営者が「それいつ終わるの?」と聞くと、コミットメントのように聞こえます。

そのため、回答する人は固めな目標を出すケースが多いと思いますし、経営者自身も正しい見積りを聞ける機会を失ってます。 ターゲットと見積りとコミットメントを分けて聞くことで、正しくコミュニケーションが取れるので、ぜひ使ってみてほしいです。

一緒に仕事をする仲間を募集しています

ヘンリーでは、他にも社内勉強会を積極的に開催して、得た知識を事業推進にみんなで活かしています。 全ては、日本の持続可能な医療を実現するために。 困難で大変なことも多々ありますが、今後も高いターゲットを掲げて事業推進していきます! ぜひ、興味のある方は、採用サイトよりご連絡ください。

参考にした本

www.amazon.co.jp

はじめまして nabeo です

今年の6月にヘンリーに SRE として中途入社しました nabeo (id:nabeop) です。前職では toC 向けのコンテンツプラットフォームサービスと toB 向けのテクノロジーソリューションサービスなどを展開している会社で共通の基盤を開発運用している部署で SRE をやっていました。

ヘンリーに入ろうと思った理由

今年の初め頃から以下を軸にしてなんとなく転職活動を開始していました。

  • 自分にとって新しいチャレンジをしたい
  • エンジニアとしての成長ができる場に身を置き続けたい

エンジニアとしての成長はわりとこだわりが強くて、自分よりも若いエンジニアがメキメキ成長をしている様子を目の当たりにしていたので強い危機感をもっていました。そんな中で、カジュアル面談や面接を通じてヘンリーには優秀なエンジニアが在籍していそうだし、一緒に仕事をしたいと思えました。とくにカジュアル面談や面接では同じ SRE として活躍されている戸田さんといくつかの技術的なディスカッションをさせてもらいましたが、ディスカッション自体が楽しく一緒に働きながら組織を成長できそうだという印象をもてたことが大きかったです。

また、会社としても勢いがあるが、SRE 的な文脈ではまだまだ未整備なところがあって、SRE として技術分野だけでなく、文化などより広範囲なところで活躍できそうというイメージがあったことも魅力でした。とくに電子カルテやレセコンという医療分野をターゲットにしたプロダクトを開発/運用するにあたり SRE 的な知見をどのように導入して活用していくのかというのは入社前にはまったく想像がつかずとてもワクワクしたことを覚えています。

ヘンリーのオンボーディング

ヘンリーのオンボーディングでは「初日」「最初の1週間」「最初の1ヶ月」の3つの粒度でヘンリーが開発しているクラウド電子カルテ・レセコンシステムの Henry の概要や開発で必要となってくるドメイン知識だけでなく、ヘンリーの文化やプロダクトの機能などを効率よくキャッチアップできるようにタスクが組まれていてとても助かりました。実際にヘンリーに入るまではレセコン*1という言葉も聞いたことないし、病院やクリニックでどのような業務フローがあるかもわかっていない状態でしたが、必要な知識が効率よく学べるようにチェックリスト形式になっていたのでスムーズにキャッチアップできたと思います。

入社初日の TODO リストとして業務で使用するツールで実施するべき初期設定のリストがある。
入社初日の TODO リスト

また、実務でもこれまでに発生した障害や不具合の対応などをまとめて開発に及ぼした影響などが判別しやすい指標を考えるというタスクを渡されて、開発の進め方や Henry の大まかなシステム構成などを俯瞰して眺めることができました。とくに不慣れな医療という現場でどのようにシステムが使われているかを座学で学んだ医療ドメインの知識と照らし合わせることができたのでコスパが良いキャッチアップタスクだったなと思っています。

これから何をするのか

前述のとおり Henry はこれからも成長を続けていくプロダクトですが、SRE 的な視点だとまだまだ未整備なところがあります。当面はサービスの開発と安定性の維持にバランスよく、かつ、最大の速度を出せるように SRE 的な知見の導入が主なミッションになっていくのかなと思っています。

特に SRE として絶対に欲しい SLI や SLO もまだ十分に整備されている状態とは言えず、Henry のプロダクト特性に応じた SLI や SLO を策定し運用する必要があると考えています。

また、前職では自分自身のアウトプットの他にも組織としてのアウトプットを後押ししていましたが、ヘンリーでも何らかの形で他者のアウトプットを後押しすることで、ヘンリーのエンジニアについてたくさんの人に知って欲しいと思っています。

一緒に仕事をする仲間を募集しています

SRE 職にかぎらずヘンリーでは一緒に働く仲間を募集しています。これからも成長が見込める弊社で一緒に働いてみませんか?興味のある方は以下の採用サイトからぜひコンタクトしてみてください。

*1:レセプト(診療報酬明細書)を作成するときに使用するレセプトコンピューターのことです。

StorybookをReact以外のプロジェクトでも使いたい!

はじめまして。今月から株式会社ヘンリーのフロントエンドエンジニアをしている kobayang です。この記事では Storybook を React や Vue などの UI ライブラリを使っていないプロジェクトでも活用できるかも、という話をしていこうと思います。

Storybook

storybook.js.org

もはや説明不要な気がしますが、Storybook は UI を実装する上で便利な、UI プレビューのためのライブラリです。また、プレビューだけでなく、Jest から Story を呼び出すことで簡単にテストが書けたり、Chromatic と連携することで、VRT ができるようになったりと、開発の生産性をあげるだけでなく、プロダクトの安定性を上げるためにも重要なライブラリになっています。

そんな便利な Storybook なのですが、普段当たり前のように使っているがゆえに、Storybook がないと不安になる、ということに気づいてしまいました。その時に触っていたコードは、HTML のテンプレートエンジンで書かれていたため、プレビューできないのは仕方がないかなと思っていたのですが、そこで、あることを思い至りました。

HTML で動いているんだから Storybook で動かないはずはない、と。

Storybook for HTML

ここから本題です。先に結論を言ってしまえば、HTML を Storybook で動かすことができます。新規プロジェクトの場合は、Storybook 初期化時に、 —type html を指定することで、HTML を動かすための最小限のセットアップが行えます。

// pnpm の場合
pnpm dlx storybook@latest init --type html
// npm の場合
npx storybook@latest init --type html

Storybook v7 からは Vite でも動くようになったので、Vite or Webpack のどちらを使用するか、という選択が出ます。今回は Webpack を選択します。

初期化が完了すると、Storybook の設定と、stories フォルダにサンプル用のプロジェクトが追加されます。

Storybook の設定

.babelrc.json
.storybook/main.js
.storybook/preview.js
package.json

サンプルファイル

stories/Button.js
stories/Button.stories.js
stories/Header.js
stories/Header.stories.js
stories/Introduction.mdx
stories/Page.js
stories/Page.stories.js
stories/assets/code-brackets.svg
stories/assets/colors.svg
stories/assets/comments.svg
stories/assets/direction.svg
stories/assets/flow.svg
stories/assets/plugin.svg
stories/assets/repo.svg
stories/assets/stackalt.svg
stories/button.css
stories/header.css
stories/page.css

stories に出力されたサンプルファイルは邪魔なので全部消してしまいます。

さて、もう少し変更されたファイルを見てみましょう。まずは package.json から。

"devDependencies": {
  "@babel/preset-env": "^7.22.5",
  "@storybook/addon-essentials": "^7.0.20",
  "@storybook/addon-interactions": "^7.0.20",
  "@storybook/addon-links": "^7.0.20",
  "@storybook/blocks": "^7.0.20",
  "@storybook/html": "^7.0.20",
  "@storybook/html-webpack5": "^7.0.20",
  "@storybook/testing-library": "^0.0.14-next.2",
  "react": "^18.2.0",
  "react-dom": "^18.2.0",
  "storybook": "^7.0.20"
}

上記のパッケージが追加されました。 HTML を選択しているのに React が追加されているのが気になりますね。

今回、Storybook を動かすのに本当に必要なのは、以下のパッケージになります。

  • storybook
  • @storybook/html
  • @storybook/html-webpack5
  • @babel/preset-env

さて、ここで、 storybook/main.js をみてみると以下のような設定がされています。

framework: {
  name: "@storybook/html-webpack5",
  options: {},
}

Storybook v7 から導入された Framework によって隠蔽されてしまっていますが、どうやら、この設定によって、 HTML を Storybook が起動できるようになっていそうです。

HTML を Story として読み込んでみる

試しに、適当な index.html を作って Story に読み込ませてみましょう。

<h1>test</h1>
import html from "./index.html";

export default { title: "index" };
export const Preview = () => html;

無事に動きました 🎉

ではこれはどうでしょう?Story に直接 HTML を書いてみます。

export default { title: "index" };
export const Preview = () => `<h1>test</h1>`;

これも無事に動きました 🎉

適当に Story を試しただけですが、このことから、ファイルを HTML として出力さえすれば Storybook で動かすことができることが分かりました。

index.html が読み込めているのは、 @storybook/html-webpack5 が HTML を Webpack のローダーとして設定しているためです。ということは、ローダーさえセットできれば、任意のプロジェクトを Storybook で動かすことができるはずです。

実際にやってみましょう。

テンプレートエンジンを Storybook で動かす

UI ライブラリではないプロジェクトを Storybook により動かせるようにしたいと思います。今回は例としてHandlebars というテンプレートエンジンを使います。詳細は割愛しますが、以下のように、テンプレートを記述することができます。

<h1>{{title}}</h1>

Handlebars.js によってテンプレートをコンパイルできます。コンパイルするためにパッケージをプロジェクトに追加します。

pnpm add handlebars

以下のようにコードを書くことで、 Handlebars で書かれたファイルを HTML に変換することができました。

import Handlebars from "handlebars";

const source = `<h1>{{title}}</h1>`;
const template = Handlebars.compile(source);
const html = template({ title: "Test" });
// => <h1>Test</h1>

Story に直接書いてみる

コンパイルすることで HTML を生成することができるところまで分かったので、このコードを Story に記述してみます。

import Handlebars from "handlebars";

const source = `<h1>{{title}}</h1>`;
const template = Handlebars.compile(source);
const html = template({ title: "Test" });

export default { title: "index" };
export const Preview = () => html;

動きそうですが、これは残念ながらエラーになってしまいました。

handlebars モジュールを読み込む際に、 Module not found: Error: Can't resolve 'fs' というエラーになります。本質的なエラーではないですが、Storybook を起動するために修正する必要があります。fs module を何らか mock してあげれば回避できそうです。

ググって見つかった stackoverflow の回答に従って、以下のように Storybook の main.jsconfigwebpackFinal を記述します。

webpackFinal: async (config) => {
  config.resolve.fallback.fs = false;
  return config;
},

これで、Storybook が起動しました 🎉

ファイルから Import する(その1)

上記の直接 Story に書いていくやり方は、さすがに古典的すぎるので、せめてファイルからインポートできるようにしたいです。というよりファイルからインポートできないと、プレビューとしての意義を果たせません。察しの良い方はすでにお分かりの通り、この辺りから Webpack のローダーをカスタマイズしていきます。

先ほど修正した webpackFinal のフィールドがありますが、ここから Storybook で動かす Webpack の設定を変更できるようになっています。

とりあえずファイルから読み込めるようにするために、raw-loader を使ってみましょう。 raw-loader は名前の通り、ファイルをテキストとして読み込むローダーです。

まず、 raw-loader をパッケージに追加します。

pnpm add -D raw-loader

.storybook/main.jswebpackFinal に、 .handlebars 拡張子のファイルに対して raw-loader を適用するようにセットしてみましょう。

webpackFinal: async (config) => {
  config.module?.rules?.push({
    test: /\.handlebars$/,
    loader: "raw-loader",
  });
  config.resolve.fallback.fs = false;
  return config;
},

次に、 index.handlebars を作成し、そこにテンプレートを記述します。

<h1>{{title}}</h1>

これで準備が整いました。先ほど書いた source の部分を index.handlebars から import するように変更してみましょう。

import Handlebars from "handlebars";
import source from "./index.handlebars";

const template = Handlebars.compile(source);
const html = template({ title: "Test" });

export default { title: "index" };
export const Preview = () => html;

動きました 🎉

ファイルから Handlebars で書かれたテンプレートを Story に import できるようになりました。ここまでで必要最小限のプレビュー機能が達成できたことになります。

しかし、テンプレートが一つのファイルで収まることは稀でしょう。実際には、テンプレートは別のテンプレートに依存します。Handlebars においても、テンプレートから別のテンプレートを呼び出すことが可能です。

以下のように記述します。

<h1>{{title}}</h1>
{{>subtitle}}

subtitle.handlebars

<h2>{{subtitle}}</h2>

上記で記述した Story だと、 subtitle を読み込むことができないので、以下のようなエラーになってしまいます。

これを解決するには、 registerPartial を使って、コンパイルの前に、依存する Partial Template を登録しておく必要があります。

import Handlebars from "handlebars";
import source from "./index.handlebars";
import subtitle from "./subtitle.handlebars";

Handlebars.registerPartial("subtitle", subtitle);

const template = Handlebars.compile(source);
const html = template({ title: "Test", subtitle: "Sub" });

export default { title: "index" };
export const Preview = () => html;

これで、動くには動きますが、依存ファイルが増えるたびに Story にそれを追加しなければいけないのは何とも筋が悪いです。次はこれを何とかしましょう。

ファイルから import する(その2)

Handlebars には幸いにも Webpack のローダーが存在します。

github.com

このローダーを設定することで、先ほどの依存をいい感じに解決してくれるようになります。

設定してみましょう。

まずは、handlebars-loader をパッケージに追加します。

pnpm add -D handlebars-loader

Storybook の Webpack のローダーを変更します。

config.module?.rules?.push({
  test: /\.handlebars$/,
-  loader: "raw-loader",
+  loader: "handlebars-loader",
});

変更はこれだけです。この設定により、 Handlebars で書かれたテンプレートがローダーによって解決されるようになりました。

以下のような Story を記述すれば先ほどのテンプレートも動くようになります。

import template from "./index.handlebars";

const html = template({ title: "Test", subtitle: "Sub" });

export default { title: "index" };
export const Preview = () => html;

無事に動きました 🎉  これで、Handlebars によって記述された HTML をいい感じに Storybook で確認できるようになりますね!

まとめ

HTML を Storybook で動かす方法について簡単に紹介しました。また、Handlebars というテンプレートを例にとって、コンパイル可能であれば raw-loader を使ってテンプレートファイルをプレビューできること、さらに Webpack のローダーがあれば、より良い感じに Story を記述できることを示しました。

ぱっと思いつく適用例としては、 erbhaml の Webpack ローダーが存在していそうなので、Rails のプロジェクトにも Storybook が適用できるかもしれません。(注: 試してないので分かりません)

github.com

github.com

今回例に出した Handlebars は、Vite 用のローダーがなかったため、Vite では起動できませんでしたが、設定さえしてあげれば Vite でも同様なことができるはずです。

また、実際のプロジェクトは、より複雑だったり、依存するファイルがプログラムによって制御されていたりと、ローダーを直接使うことが難しかったりするかもしれません。

しかし、Webpack をいい感じに設定して HTML さえ出力できれば Storybook を使えることが分かってもらえたと思うので、もし、Storybook が入ってないプロジェクトがある場合には、ぜひ一度導入をチャレンジしてみて欲しいなと思います。

皆様がより良い Storybook ライフを送れますように。敬具。

参考

今回説明で使った成果物は以下のリポジトリにあります。

github.com

【Scala 3 macroがすごい】Compiletime API編

株式会社ヘンリーでメタプログラミングに没頭しているgiiitaです。

突然ですが皆さんはメタプログラミングに触れたことがあるでしょうか? プログラミング言語によって様々なメタプログラミングの機能があります。Javaやその派生言語では馴染み深い「リフレクション」はその代表例ですが、LispやRust, Scalaなどにはマクロと呼ばれる、compile前にコードを操作する仕組みがあります。 そんな中でも最近急激な変化を遂げている Scala3 のMacroがとんでもなくすごいんですが、なかなかまだ情報が出回っておらず、手を出すにはあまりにも敷居が高くなっているため、皆さんに面白さを知ってもらうべく紹介します。

マクロって何ができるの?

そもそもマクロに手を出しづらい背景として、何に使えるのか、いつ使うべきなのかよくわからないからというのが大きいのではないでしょうか? 現に私自身、手を出すまでイメージがつかず、わざわざ難解でExperimentalな機能 (Scala2系当時) を使ってまで何かをしたいというモチベーションがありませんでした。 とは言いつつも、今なら明確にこういうケースで使うべきですと言えるわけではないので難しいところですが、基本的には コードの変換自動生成 といったところが主な目的です。 そして何より、それら成果物を compilerによって静的に検査できる というのが旨味なわけです。

使ってみよう

今回はひとまず先日の3.3.0リリースを祝って、3.3.0でいろいろやっていきたいと思います。

Setup

まずはModule構成です。マクロを使用したmoduleは、利用するmoduleのbuild時にはbuildが完了していないといけないので、macroを定義するmoduleと呼び出すmoduleが必要です。 (確かこれもScala3で変更があったような気も...

build.sbt

lazy val Scala3_3  = "3.3.0"
scalaVersion in Scope.Global := Scala3_3

lazy val root = (project in file("."))
  .aggregate(
    macroModule,
    runtimeModule,
  )
lazy val macroModule = (project in file("macro-module"))
  .settings(
    libraryDependencies ++= {
      Seq(
        "org.scala-lang" %% "scala3-compiler" % scalaVersion.value,
      )
    }
  )
lazy val runtimeModule = (project in file("runtime-module"))
  .dependsOn(macroModule)

もはや何も特別な事はありません。 さて、何を作ろうか悩むところですが、わかりやすくここは誰もが通る道という事で、JsonParserを作っていきたいと思います。 String => JsonJson => String が必要なのでI/Fを切りましょう。この時、マクロから利用するものはmacro以下のmoduleに定義する必要があります。

trait Read[T] {
  def read(json: Json): T
}
trait Write[T] {
  def write(t: T): Json
}
trait Both[T] extends Read[T] with Write[T]

ここでは変異境界は無しでやっていきます。

trait Json extends Serializable {
  def nameOf(key: String): Json
}
case class JsonString(value: String) extends Json {
  override def toString: String = s""""$value""""
  override def nameOf(key: String): Json = JsonNull
}
case class JsonAny(value: String) extends Json {
  override def toString: String = value
  override def nameOf(key: String): Json = JsonNull
}
case class JsonArray(values: Seq[Json]) extends Json {
  override def toString: String = s"""[${values.mkString(",")}]"""
  override def nameOf(key: String): Json = JsonNull
}
case class JsonObject(values: Map[JsonString, Json]) extends Json {
  override def toString: String = s"""{${
    values.map { case (key, value) =>
      s"$key:$value"
    }.mkString(",")
  }}"""

  override def nameOf(key: String): Json = values(JsonString(key))
}
case object JsonNull extends Json {
  override def nameOf(key: String): Json = this
}

Jsonの型としては一旦こんなところでしょう。エスケープ処理は面倒なので考慮していません。 性能面では話にならないのであまり参考にしないでください。String to Jsonの処理もちゃんとやると結構面倒なのでイメージです。

さて、ここからいよいよマクロを書いていきますが、Scala3のマクロには2種類あります。

一つはお馴染み、AST(抽象構文木: Abstract Syntax Tree) を直接的に操作する方法です。 もう一つはScalaのコードがCompileされる前段のフェースでPrecompileされる、半マクロ的なものです。これは、scala3-libraryの scala.compiletime パッケージにAPIがあります。

Compiletime API

後者は近しいものがScala2にもありましたが、非常に強化されました。非常に簡単かつ、安全に使用できるAPIなので、出番も多いかもしれません。これはCompiletime APIと呼ばれています。 まずはこれを用いて、CaseClassのJson変換器を導出していきます。

// runtimeModule
object CodecGenerator {
  inline final def CaseClass[T]: Both[T] = InferCodecs.gen[T]
}

実行module側からinline functionを呼び出しています。 InferCodecs.gen は単なるinline関数であり、マクロ展開はされませんが、前述の通りPrecompileによってinline化されます。

// macroModule
object InferCodecs {
  inline def gen[A]: Both[A] = {
    summonFrom[Both[A]] {
      case given Both[A] => implicitly[Both[A]]
      case _: Mirror.ProductOf[A] => InferCodecs.derivedCodec[A]
      case _ => error("Cannot inferred.")
    }
  }

  inline def derivedCodec[A](using inline A: Mirror.ProductOf[A]): Both[A] =
    new Both[A] {
      override def write(value: A): Json = Writes.inferWrite[A].write(value)
      override def read(value: Json): A = Reads.inferRead[A].read(value)
    }

  trait ProductProjection {

    transparent inline def inferLabels[T <: Tuple]: List[String] = foldElementLabels[T]

    transparent inline def foldElementLabels[T <: Tuple]: List[String] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          constValue[t].asInstanceOf[String] :: foldElementLabels[ts]
      }
  }

  object Reads extends ProductProjection {

    inline def inferRead[A]: Read[A] = {
      summonFrom[Read[A]] {
        case x: Read[A] => x
        case _: Mirror.ProductOf[A] => Reads.derivedRead[A]
        case _ => error("Cannot inferred")
      }
    }

    transparent inline def derivedRead[A](using A: Mirror.ProductOf[A]): Read[A] =
      new Read[A] {
        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemReads: List[Read[_]] =
          inferReads[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemReads).zipWithIndex

        private[this] val elemCount = elemSignature.size

        override def read(value: Json): A = {
          val buffer = new Array[Any](elemCount)
          elemSignature.foreach { case ((label, read), i) =>
            buffer(i) = {
              read.read(value.nameOf(label))
            }
          }
          A.fromProduct(
            new Product {
              override def canEqual(that: Any): Boolean = true

              override def productArity: Int = elemCount

              override def productElement(n: Int): Any =
                buffer(n)
            }
          )
        }
      }

    private inline def inferReads[T <: Tuple]: List[Read[_]] = foldReads[T]

    private inline def foldReads[T <: Tuple]: List[Read[_]] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          inferRead[t] :: foldReads[ts]
      }
  }
  object Writes extends ProductProjection {
    inline def inferWrite[A]: Write[A] = {
      summonFrom[Write[A]] {
        case x: Write[A] => x
        case _: Mirror.ProductOf[A] => Writes.derivedWrite[A]
        case _ => error("Cannot inferred")
      }
    }

    inline def derivedWrite[A](using A: Mirror.ProductOf[A]): Write[A] =
      new Write[A] {
        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemDecoders: List[Write[_]] =
          inferWrites[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemDecoders).zipWithIndex

        private[this] val elemCount = elemSignature.size

        override def write(t: A): Json = {
          val entries = t.asInstanceOf[Product].productIterator.toArray
          JsonObject(
            (0 until elemCount).map { i =>
              JsonString(elemLabels(i)) -> elemDecoders(i).asInstanceOf[Write[Any]].write(entries(i))
            }.toMap
          )
        }
      }

    private inline def inferWrites[T <: Tuple]: List[Write[_]] = foldWrites[T]

    private inline def foldWrites[T <: Tuple]: List[Write[_]] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          inferWrite[t] :: foldWrites[ts]
      }
  }
}

複雑に見えますが、順に見ていきます。

InferCodecs.gen
  inline def gen[A]: Both[A] = {
    summonFrom[Both[A]] {
      case given Both[A] => implicitly[Both[A]]
      case _: Mirror.ProductOf[A] => InferCodecs.derivedCodec[A]
      case _ => error("Cannot inferred.")
    }
  }

summonFrom というのはcompiletime APIで、scala2における implicitly[T] に近いものです。 パターンマッチによって、 given Both[A] がimplicit scopeに見つかればそれを返して終わります。

case _: Mirror.ProductOf[A] もpackageこそ変わっていますが馴染み深いのではないでしょうか?要するにケースクラスであり、メンバの型情報が導出できれば、という分岐で、 given Both[A] は見つからないけど、型情報導出できそうなのでReadとWriteをそれぞれ導出するぜ!という処理です。

Reads.inferReads
    inline def inferRead[A]: Read[A] = {
      summonFrom[Read[A]] {
        case x: Read[A] => x
        case _: Mirror.ProductOf[A] => Reads.derivedRead[A]
        case _ => error("Cannot inferred")
      }
    }

これも一緒ですね、さっきのは Both[T] の導出だったのに対し、今回は Read[T] の導出になっているだけです。

Reads.derivedRead
    transparent inline def derivedRead[A](using A: Mirror.ProductOf[A]): Read[A] =
      new Read[A] {
        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemReads: List[Read[_]] =
          inferReads[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemReads).zipWithIndex

        private[this] val elemCount = elemSignature.size

        override def read(value: Json): A = {
          val buffer = new Array[Any](elemCount)
          elemSignature.foreach { case ((label, read), i) =>
            buffer(i) = {
              read.read(value.nameOf(label))
            }
          }
          A.fromProduct(
            new Product {
              override def canEqual(that: Any): Boolean = true

              override def productArity: Int = elemCount

              override def productElement(n: Int): Any =
                buffer(n)
            }
          )
        }
      }    

    private inline def inferReads[T <: Tuple]: List[Read[_]] = foldReads[T]

    private inline def foldReads[T <: Tuple]: List[Read[_]] =
      inline erasedValue[T] match {
        case _: EmptyTuple =>
          Nil
        case _: (t *: ts) =>
          inferRead[t] :: foldReads[ts]
      }

さて、いよいよcompiletime APIの本領発揮です。

inferReads[T <: Tuple]: List[Read[_]] = foldReads[T]

これはシグネチャから想像できる通り、任意のTupleのサブタイプ T の要素から Read[T] を導出してListで返しています。

inline erasedValue[T] これはまぁ見ての通りですが、 T <: Tupletypeunapply 的な操作である事は想像できます。 ※リファレンス

EmptyTuple か、 _: (t *: ts) かのパターンがあるという事は、 (A, B, C)(A *: (B, C))unapply されるという事ですね。そして A, B, C が順次導出されていきます。WritesもReadと同じですね。

        private[this] val elemLabels = inferLabels[A.MirroredElemLabels]

        private[this] val elemReads: List[Read[_]] =
          inferReads[A.MirroredElemTypes]

        private[this] val elemSignature = elemLabels.zip(elemReads).zipWithIndex

        private[this] val elemCount = elemSignature.size

この辺はなんとなく想像しやすいと思います。型情報からラベル (変数名) とそれに対応する再起的に導出された Read[_]を持ち、 read(value: Json) が呼び出された際にはマッピングして各メンバをdeserializeしますよ、という事ですね。

さて、これでケースクラスのCodecは導出できるようになりました。後は末端のプリミティブ型のCodecが事前に定義されていて、implicit scopeに見つかれば動きそうです。 サンプルとして、末端がStringかIntであればいいとしましょう。

object Codecs {
  given Both[Int] with {
    override def read(json: Json): Int = json.toString.toInt
    override def write(t: Int): Json = JsonAny(t.toString)
  }

  given Both[String] with {
    override def read(json: Json): String = json.toString

    override def write(t: String): Json = JsonString(t)
  }
}

object Main extends App {
  export Codecs.given

  case class Root(child: Child)
  case class Child(str: String, num: Int)

  val codec = InferCodecs.gen[Root]
  val raw = codec.write(
      Root(
        Child("A", 1),
      )
  )
  println(raw)

  val value = codec.read(raw)
  println(value)

}

出力結果はこんな感じになりました。

{"child":{"str":"A","num":1}}
Root(Child("A",1))

雑な作りではありますが、雰囲気はつかめたのではないでしょうか? このサンプルでは、 given Both[T]Mirror.ProductOf[A] のいずれかしか処理していないので、間に Seq[T] などが入ってきてしまうとcompileできなくなります。 そして、マクロの呼び出し元はcompileの時点で異常に気付くことができるようになるわけです。

※本内容には、一部個人的な解釈を含んでいます。

最後に

面白い反面、マクロの運用にはなかなかリスクも伴います。 Scala3はまだまだガンガン開発が進んでいるので、3.0 ~ 3.3にかけて結構APIが変わっていたり、それだけならまだしもシグネチャのシンボルがこっそり変わったりして、OSSによってはScala3.xごとにsrcクラスパスを分けていたりします。 すでに、cross buildという観点では、同一のsrcクラスパスで各マイナーバージョンでのマクロmoduleのビルドはなかなか難しいかもしれません。

Scala2と比較して、構文literal直書き (scala.reflect.api.Quasiquotes) ができなくなった分マシにはなりましたが、結構めちゃくちゃなことをやっているところも少なくないので、適当なOSSのマクロ関数を読む事はあまりお勧めできません。 何より一番厳しいのは情報の少なさで、実装を読むか、公式 に聞く以外お手上げなんてことも... そんな中でも少しでも情報を集めている方は、私がメンテナンスしている DI framework のコードでも参考にして見てください。少しは助けになるかもしれません。

次回はもっと自由度の高いMacro APIについてお話ししようと思います。 Techオタクなあなた、是非私たちとお話しましょう!

jobs.henry-app.jp