Runner in the High

技術のことをかくこころみ

ソフトウェア開発におけるクリエイティビティの最小化、認知負荷

前提としてクリエイティブな仕事は再現性が低い。しかし逆に言えば再現性があってはいけないものがクリエイティブであり、再現性がないからこそクリエイティブであると言える。アートのように非再現的なものはクリエイティブであり、再現性が低く刹那的な成果物であることに意味がある。

ソフトウェア開発にもまたアート的なクリエイティビティが求められつつも、ビジネスとしての利益追求では再現性が同時に求められることが多い。従って、多くの現場ではソフトウェア開発を再現性の高い労働集約的な仕事に転換しようとする。むしろ、そうしなければ開発組織の規模をスケールさせることができない。

ここで言うクリエイティビティの有無とは本質的に技術力とイコールであり、その具体性の表出はフレームワークプログラミング言語を使うことではなく、逆にそれらを生み出す側にある。このレベルの技術力を持つ人材を集め続けるのは無理があるが、一方で技術力を求めなければ採れうる人材の幅は圧倒的に広くなる。

これは「クリエイティブではないソフトウェア・エンジニアを雇おう」という主張ではなく、クリエイティビティの尺度で境界を区別して適材適所的に組織を構成したほうが結果的にスケールに繋がるという話であり、身近な例で表現するならばファストフード・チェーンの店舗とバックオフィスのような関係で開発組織を構成していくようなイメージ。チームトポロジーで言うならば、店舗はストリームアラインドであり、バックオフィスがプラットフォームになる。バックオフィスのメンバーは店舗でオペレーションを直接やらず、代わりに新商品やオペレーション、教育プログラムをインターフェイスとして提供*1する。バックオフィスの仕事は非定型的で再現性が低いクリエイティビティが必要な業務である。

チームトポロジーの本質は組織の設計によって認知負荷の境界分割を行うことである。もしもファストフード・チェーンの店舗レベルの人間に普段の業務と並行して現場で商品や教育プログラムの開発を片手間のタスクで行わせるとしたら、それは店舗というチームにバックオフィスが持つクリエイティビティの要求が持ち込まれることに他ならない。これはまさに認知負荷の上昇であり、結果的に本来の責務として与えられていたオペレーションへ影響を及ぼす。やるべき仕事が多様になり、個々人に求められる認知負荷の振れ幅が大きくなると業務は属人化しはじめ、再現性の高い生産が行えなくなっていく。

エンジニアリングに話を戻して具体的にするならば、あるチームの仕事の中に社内で使っているサードパーティ・ライブラリの基盤に新機能追加のPRを出すタスクと、単にアプリケーション側でフレームワークの新しい機能を使うように書きかえるだけのタスクが同列に存在しているような状態を想像するといい。全員が同じレベルで全てのタスクに着手できない状態である。開発者ごとに相対的な認知負荷に対する許容範囲の差分があり、メンバー間の認知負荷の差分が結果的に技術的負債を生み出す原因になる。また、この手の負債は属人性がボトルネックになることで回収されないままになる。*2

そのような環境で認知負荷の容量が大きい人が生み出したコードは容量の小さい人からすれば、過度に難易度が高いが故の相対的な技術的負債だと言える。別の見方をすれば技術的負債とは属人性がもたらした「状態」であり、開発組織における人員の能力、特性、流動性を考慮して適切なレンジの認知負荷になるようコードベースの複雑性をコントロール*3できれなければ、どんなに綺麗に設計されたコードであっても組織の状態により技術的負債になったりならなかったりする。

これらは、いずれにしても同一のチームで求められるクリエイティビティのレンジと、個々人が対応できる認知負荷の容量のバラつきが大きすぎることが原因である。認知負荷は他者の発揮するクリエイティビティの発露によって生まれる結果であり、この差分が大きくなっている箇所をチームとして分割せねばならない。

その際には、育成や指導でメンバーのクリエイティビティの下限をどうにかして上げようとするのではなく、構成されるメンバーに応じて上限にリミットをかけるためにチームを分割・構成するべきだ。

*1:一方で、自分の大学時代の先輩に日本マクドナルドへ入職した方がいるのだが、バックオフィスの場合には実際のオペレーションなどを理解できるよう一定期間店舗で働く必要があるとのこと。オペレーションの効率性が重要な店舗側ではバックオフィスのことを知る必要がないが、逆にバックオフィスは店舗レベルのオペレーションを俯瞰して理解しなければいけない。

*2:よくある「このコードは〇〇さんしか触れないやつだから...」「〇〇さんいないと分からないコードだこれ」みたいなやつ

*3:技術選定やアーキテクチャ選定とはまさにこの認知負荷レンジのコントロールそのものであり、Goでクリーンアーキテクチャ的な設計方針を取ろうがRailsを使おうがHanamiを使おうが、その意思決定が結果的にはどう評価されるかはすべて開発組織が将来的にどういった人材で構成されるかに依存している。

野良社内ツールと開発生産性、プラットフォーム・エンジニアリング

よくある野良の社内ツールは、開発生産性を向上させるための手段としてスポットで生まれることが多い。

たとえば、定期的に依頼されて手作業でキックしているバッチ処理を誰かがAPI化したり、それがCLIで実行できるようになったり、あるいは不特定多数の人々が手でやっている作業が有志で自動化されツールになるなど。そして社内の口コミや告知で伝搬され、使われていく。

出来の良い社内ツールは、野良だとしても開発チームが普段の開発プロセスのなかで意識したくない複雑性や実装の詳細をうまく抽象化し、認知負荷を下げる役割を果たしている。見方を変えれば、社内ツールはチーム・トポロジー*1でいうところのX-as-a-serviceインタラクション・モードの具象化のひとつだと言える。開発チームと社内ツールを開発する人間を社内ツールがインターフェイスとなって接続している。広い目線で見ると、これはプラットフォーム・エンジニアリングの土壌なのだが、単に野良で行われてそのままになるケースが非常に多い。これを放置せず、プラットフォーム・エンジニアリングに形を変えて開発組織に持ち込めるか否かが、単なる開発組織とそれ以上の存在を隔てるポイントなのではないかと最近は感じている。

インタラクション先として責任を持つチームがいなければ、インタラクション・モードとしての社内ツールは最適化されない。社内ツールをコミュニケーション・インターフェイスとして扱うことでチーム間のインタラクション・モードが最適化されるし、結果的に社内ツールのあるべき姿... 機能、体験、そしてそれ以上、そもそもツール云々ではなくもっと本質的な開発組織が抱えている生産性の課題が導き出される。

誰かが片手間で散発的に作っているような社内ツールは短期的には生産性を向上させうるが、根本的な課題にはアプローチしない。誰もその社内ツールに責任を持っていないので、表面的に物事が楽になればそこで満足して終わる。なんならメンテもされない。本当はそこから社内ツールの成長にフルタイムである程度コミットするチームが生まれるのが理想だが、散発的なままその取り組みを終えてしまう例は多い。

また、個人的な体感として社内ツールは割と雑に扱われがちで、あれば嬉しいが無くてもいいと思われている(でも、なければ我慢して開発をするハメになる)ことが多い印象。雑に扱われる一方、ある程度効果的な社内ツールを作るためには開発プロセスを俯瞰してかつ抽象化する能力が必要なことが多く、それができる/できないの間には大きな溝がある。できる人は言われなくても勝手に良いものを作るし、できない人は絶対にやらない。技術力があったとしても、効果的なツールを作れるとは限らない。このあたりには本能的・経験的な嗅覚の有無*2を感じる。この手の嗅覚を持つ人たちには、先陣を切らせて開発組織の生産性改善をリードさせるべきだ。

いずれにしろ、社内ツールは開発チーム間のインタラクション・モードを最適化するとっかかりとしてヒントを出してくれていると思うことが多い。そういったものに対して「便利だね、作ってくれてありがとう」で終わらせず、深ぼって目を凝らしてみると、新しく見えてくるものはいくつもある。

*1:https://bliki-ja.github.io/TeamTopologies

*2:CLIやWeb APIインターフェイス設計だとかそういうやつは、技術力というよりもデザイン寄りの志向性という感覚がある。便利で使いやすいものを作る人は、さまざまな角度から「便利とはどういうことか」を考えながら作っている。フレームワークの設計とかもそう。

2023年を振り返る

今年もそろそろ終わってしまうので、若干気が早いが恒例のやつをここいらで一発。

Tailorに入社して1年経った

去年11月ごろに入社して、気づいたら1年が経過していた。スタートアップのスピード感を全身で感じる1年だった。

note.com

2023年はTailorプラットフォーム自体の機能拡充とインターフェイスの安定化がだいぶ進み、プロフェッショナルサービス(後述)で開発する顧客向けシステムもいくつかメンテナンスモードに入り始めているものが出てきたことが感慨深い。

思い返せば、自分が入社した当時はプラットフォームに足りない機能や細かいバグ、一貫性のない仕様やインターフェイスが多く、まずTailorを使ってストレートにアプリケーションを作るという行為そのものが無理筋だった。入ってすぐの2023年末はガラガラのWeWork KABUTO ONEでプラットフォームで使われている時間計算系のCel-go*1用のカスタム関数にあるバグ修正のPRを作っていたなという記憶がある。

全くフロントエンドと関係がないのだが、いくら自分のメインの職能がフロントエンド・エンジニアだとしてもプロダクト成長のことを考えたら自分のロールなんてあってもなくてもやるべきことには関係がないし、チームや個人の責任範囲のような考え方が出てきたとしても、マインドとしてはこうあるべきという感覚がある。

Tailorの組織について

ところで、弊社が具体的にどういう組織構造をしているか、エンジニアがどういう仕事をしているかという情報がまったく表に出ていない。せっかくなのでざっくり2023年末現在の状態を説明する。

Tailor社内は大きく分けるとふたつの開発チームで構成されており、Tailor本体のプラットフォーム開発を行うプラットフォーム・チームと、プラットフォームを利用して一般顧客向けのシステム開発(システム・インテグレーション)を行うプロフェッショナルサービス・チームがそれにあたる。

後者のプロフェッショナルサービスの登場背景的な話はつい先日弊社のnoteでよい記事が出たので、それを読んでもらうのも参考になる。

note.com

プロフェッショナルサービスはほぼ100%フロントエンドエンジニアのみで構成されているのもひとつ特徴であるが、これはTailorプラットフォームがバックエンドの開発を効率化するローコード・プラットフォームであり、バックエンド専任という人的リソースを必要としないからである。フロントエンドエンジニアがちょっとした設定ファイルのようなものを書くことでバックエンドの開発も担当する。

自分はプロフェッショナルサービスのいわゆるテック・リードと呼ばれるようなロールで仕事をしており、Tailorプラットフォームで開発するバックエンドからフロントエンド(今はNext.js)までを全体的に見つつ、コーディング規約や設計レビュー、開発方針の策定などをカバーしている。また、大きな視点でみると自分のやっていることは横断的にプロフェッショナルサービス組織自体の開発生産性を向上させることが目的であり、ドキュメンティングだけではなく社内向けのフロントエンド用SDK開発が別軸で動いている。

おそらく最終的なSDKのゴールはいわゆるTailorプラットフォーム向けのRefineのようなものになるのかな... という想像はしているが、この辺りのビジョンはまだまだオープンエンドなので、興味のある方はぜひカジュアルに話しましょう。

open.talentio.com

歯の矯正を始めていた

実は去年の12月くらいから始めていた。

我々は結婚式をやらない代わりにいわゆるウェディングフォトに投資をして後に残るものを作ろうという方針で計画を立てていたのだが、そうなると自分の今の歯並びで記録に残るの嫌だな...となり勢いで矯正を始めることにした。

どうやら、世間的にもコロナ禍でマスクをすることが一般的になったことから、せっかくだし矯正でもするか〜という雰囲気になった人は多いらしい。なんと、自分の大学の元同期でも歯列矯正をしている男子が2人おり、世の中の歯列矯正の流れを感じた。

ちなみに自分が通っているのは下北沢の歯列矯正専門歯科。

ortho-tokyo.com

車を買った

去年家を建てたその当時はさほど車に興味がなかったものの、そこにカースペースがあるなら停める車が欲しくなるのは人の常である。

今年の春頃に自分の地元の友人が所有するレクサスのセダンでドライブをしてからというもの、メキメキと車に対する所有欲が生まれ始め、すったもんだあり最終的に11月ごろプジョー208GTを納車した。後ろ姿が最高なので非常にイイ。

納車直後のプジョー208GT

そもそも自分は大きい車は怖いのであまり運転したくないのだが、一方で軽自動車のようなサイズ感だと高速道路でのパワーに納得がいかなかった。かといって遠出をするときによくレンタカーで借りていた1.0Lモデルのヤリスやノートなどは、サイズ感としては一番好みな部類でありつつも100キロ前後あたりの加速感には不満がある。スイフトは運転していないので分からない。

その点、プジョーの208はサイズ感としては国産のコンパクトカーにかなり近く、それでいて8速ATの1.2Lターボという自分の求める車そのものだった。 特に気に入っているのは後ろからみたエクステリアと運転席周辺のインテリアなのだが、これを書き始めると終わらなくなってしまうのでこの辺にしておく。とにかく、パワーもそこそこありかつ安っぽさもない(むしろラグジュアリーさがある)ところが超推しポイントである。

なお、燃費は大体14km/L前後かつハイオクなので、個人的には思ったより悪くはないと割り切っているが、好きじゃない人には全く乗ることをおすすめできない。燃費を考えるなら絶対国産の車のほうがいい。あと運転支援系もACC*2ブラインドスポットアシストなど、2020年以降の車なら付いてて当然なやつばかりなので、この辺に優位性はない。また、聞くところによればヨーロッパのモデルにはパーキングアシストなどがあったらしい*3が、日本で販売されている208では機能が削除されているとのこと。

ちなみに208と同じサイズ感だとAudi A1やAMG A35/A45あたりも候補にあったが、この辺りはどれも高すぎる。

その他

2023年のヒップホップ界隈は某川崎区のラッパーが起こしたアレコレでひとしきり年末に盛り上がった感があったが、自分はSound's Deliという若手のヒップホップ・コレクティブ*4にどハマりしていた。なんなら彼らの主催するイベントにも夏頃参加した。

会場には圧倒的にティーン世代っぽい人々が多くて相対的に自分の老化を感じてしまったのだが... いずれにしてもSound's Dが好きなことには変わりないのでパーティは最高だった。リスニング・パーティ(?)という名目の新曲を爆音で流すだけのイベントだったが、途中から盛り上がりすぎて普通にトラックに重ねてラップしていたのでウケた。

www.youtube.com

自分は2023年にリリースされたCHEESE BANGERあたりから入ったクチなのだが、この曲はPVの中でメンバーの名前がルビ付き字幕に出てくれるので名前が覚えやすくありがたい*5

ちなみに自分は雰囲気と声がカッコいいという理由でKaleido先輩が一番好みです。

来年に向けて

2024年はTailorのフロントエンド向けSDKを今よりもビジョナリーにしつつ、フロントエンドでの開発効率を向上させるような取り組みに時間を使う。

バックエンドの開発はTailorプラットフォームによって効率を上げられてきていると感じるが、フロントエンドに関しては今だに一般的な開発の範疇を出ていない。これからプロフェッショナルサービスは少しづつ組織の拡充が行われていくはずで、そうなった場合に業務システムと呼ばれるカテゴリにおいてもっとフロントエンドのアプリケーションが持つ要素は抽象化できると思っているし、むしろそうならないと開発組織としてスケールするのが困難になるという予想がつく。それに対する解決策が結果的にどういう形でプロダクトに現れるかはまだ分からないが、この答えのない雰囲気にまたやる気が出てくる。

あと車でたくさんドライブしたい。プジョー208のスポーツモードはステアリングやアクセルの応答性がクイックになるところがおもしろいので、ウネウネとした峠道をたくさん走りたくて堪らない。

*1:https://github.com/google/cel-go

*2:個人的な体感だがプジョーのACCはブレーキングがまだ人間的な気がしている。たとえば車種によってはギリギリまで車間を詰めてからブレーキングをするなどヒヤっとするような挙動がYoutubeなどで見られたりする。ディーラーの方曰く少し前のMAZDAのレーダークルーズもだいぶ機械的な急加減速が多かったらしい。208のACCは... 優しい。

*3:日本語マニュアルにもまるであるかのように使い方が書いてある

*4:https://en.wikipedia.org/wiki/Category:Hip_hop_collectives

*5:日本のラッパーは名前の読み方が初見でわからないことが多いのでこのフォーマットのPVは積極的に流行って頂きたい。

技術力のボトムライン、技術的負債

実際の現場に現れる負債とかクソコードとか呼ばれるものは、簡単にできるはずのものが何十にも不必要な複雑性でラップされた成果物(標準ライブラリ相当の実装を自前で全部書いていて、かつエッジケースでバグだらけ、とか)であることが多い。しかし一方で、そもそもの実現したいこと・あるべき仕様のレベルである程度複雑性が仕方ないケースに対して、最短ルートで立ち向かったものが技術的負債扱いになってしまうこともある。

かつて、某データフォーマットプロトコルで外部のアイデンティティプロバイダとデータ連携を行う機能を開発したことがあった。

さすがに1からRFCに沿って自分で全部作るのはおかしいに決まっているので、Githubで使えそうなOSSライブラリを探していたのだが、その際に見つけたものは90%は欲しい機能があるものの残り10%ほど必要な機能が足りていなかった。そこまで使えるなら、あとは自分らでフォークして足りない機能足したらええやん!と思ってフォークして使うことにしたのだが、最終的に自分以外で誰もそのOSSのコードの細かいところを理解できる(あるいはしたい)人がおらず、ちょっとした技術負債っぽい扱いになってしまった。

自分からすれば、そもそもOSSのライブラリがベースでコードも公開されているし、ハイレベルなコンセプトも全部英語だけどRFCに書かれているし、どう動くかもユニットテストを見れば分かるやん!と思っていた。けれども、結局自分以外の組織のメンバーが自分と同じことを再現性高くやれないのなら、それはいくら技術力を発揮してもないことと同じか…と思わされる出来事は、これに限らず何度かあった。

この手の組織だと、仮に中途採用でずば抜けて技術力のある人がチームに入ってきたとしても、他の人から見てその人が何をやっていて何がすごいか全く分からん…の状態になってしまうし、誰もその人のコードの品質担保ができない。そしてなにより、その人自身の成長にも繋がらない。

鳴り物入りで入ってきた優秀なエンジニアが、入社してしばらくすると「すごいって聞いてたけど、なんか大したことないな...」と思われてしまう話はよく耳にするが、それも環境がその人のトップスピードを抑え込んでしまっているケースが往々にしてある予感がしている。ソフトウェア開発がチームで行われる仕事である以上、数人だけトップスピードが出せていても広い視野で見ると解決されない物事は多い。あらゆる組織が大きくなるにつれてピラミッド上の構造をとる以上、マジョリティをコントロールするという意味でも技術レベルのボトムラインを引き上げるほうがインパクトは大きい。

技術レベルのボトムラインが低く、それなりの雰囲気が組織のスタンダードになっていると、自ずとソフトウェアの品質傾向もそれなりのものになる。いくらコードレビューだとか設計レビューで誰かが警察的な予防をするにしても、人力でやっている以上そこには限界がある。誰かが部分的に品質を上げようと思っても、その他大部分は雑なわけで「別に品質に拘んなくてもよくない?」となってしまう人の気持ちはすごくよく分かる。人間誰しも、周りがやらなくて済んでいることは自分もやりたくない。

組織の中でこの振れ幅が大きくなると、不要なルールやドキュメンテーション、いろんなレベル感に配慮した一貫性のないコードやコミュニケーションが生まれてくる。いや、もちろんドキュメントやその他諸々のアグリーメントは多かれ少なかれ必要になるが、少なく回るならそれに越したことはない。早い段階でこの手の問題を予防するには、やはり一番には採用プロセスでのクオリティー担保だし、そのベースにあるのが組織のカルチャー醸成だったりするのではないかと個人的には解釈している。

個人的な体感として、そのへんの求人サイトに転がっているWeb系のシニアレベルのエンジニアの大半においては、深ぼってみると実はソフトウェア設計やコーディングのスキルはもはや評価の対象にならないもの(というか、あって当たり前)であり、どちらかというと先に書いたような組織の技術レベルのブレ幅をいかに小さくするか、複雑性に耐えられる下限値をいかに上げていくかを考えてリーダーシップをとることのほうが、公にはしないが最終的には求められがちな能力な気がしている。

とはいえ、ここまでカバー範囲が広がってくると、そもそもそれはソフトウェア・エンジニアとしての職務に含まれるのか?という気もしてくるので難しい。コードだけ書きたいという人もいるだろうし。また、こういう仕事を誰がメインでやるべきかというのもよく分かっていない。テックリード、エンジニアリングマネージャ、VPoEらへんの誰かっぽいのは分かるが、具体的な細かい技術的な話からふわっとした抽象度の高い組織の雰囲気作り的なものまで、求められるレイヤが広そうではある。

かつ、この手の仕事はやりたいと思えない人にやらせると急に精神的に参ってしまったりすることもあるので、そういった意味でも難しさがある。

WearOSアプリでGoogle Calendarの情報を取得したい備忘メモ

先日、新しく発表されたPixel Watch 2を買った。

それに伴いWearOS用のカレンダーアプリを自作しようとしているのだが、これが想像以上にむずかしそうなので備忘メモ。

ContentResolver

知っている人にとっては当然かもしれないが、AndroidにはContentResolverという仕組みがある。これは、ざっくりいうとあるアプリから異なるアプリやシステムに対してデータのCRUDを行うための抽象化されたI/Fを提供してくれるもの。

AndroidアプリからGoogle Calendarに対するアクセスもこのContentResolverによって抽象化されており、SQLクエリのような形をしたある程度柔軟なインターフェイスでデータの取得を宣言的に書くことができる。これは結構便利。

CalendarContract

今回、自分がやりたいことはGoogle Calendarのデータを取得することなので、該当するのはCalendarContractというContentProviderのアダプタ(?)だと思われたが、WearOSアプリからクエリを発行すること自体はできても、これで返ってくる値は常に0件の予定になる。

仮説ではあるが、おそらくCalendarContractはモバイルAndroidGoogle Calendarアプリに対するContentProviderだと考えられ、従って同じAndroidであってもWearOS上にはGoogle Calendarアプリは存在しないためデータを取得できず空データになっていると推測している。

WearableCalendarContract

というわけで、WearOSではCalendarContractの代わりにWearableCalendarContractを使う。

WearableなものとそうじゃないものでそれぞれCalendarContractが存在しているのはなんとなくleaky abstractionな感じもするが、そういうもんなのだろう。

公式ドキュメントによればWearableCalendarContractはCalendarContractのサブセットであるが、自動的に何らかのタイミングでCalendarContractのデータを同期したものが反映されるのが違いとのこと。同期のタイミングは不明。手動でこの同期処理をキックすることもできなさそう。

また、このWearableCalendarContractを用いて取得できるカレンダー情報には微妙に制限があるっぽく、自分の環境では現在時刻からざっくり12-24時間前後までの予定しか取得できていないようだった。軽く調べると、WearableCalendarContractには現在時刻から24時間のtime windowに当てはまる予定の情報しか含まれないという話もある。

stackoverflow.com

ContentResolverのI/Fを用いれば BEGIN > [特定のmillisec日時] のようなクエリを発行できるので、なんとなく時間範囲で予定情報を取得できそうな気もするが、これは動かなかった。

それ以外にも、WearableCalenadrContractはsortOrderパラメタもサポートしていない(エラーがでる)ので、I/FとしてContentResolverに対応しているだけで実際に触ってみるとできないことがそこそこあり、これはundocumentedなので実際にアプリを作ろうとしてみて初めて分かるという雰囲気がある。

なんとなく、WearableCalendarContractは本格的なWearOSアプリを作るために存在しているわけではなく、ウォッチフェイスで「今日の予定」みたいなのを出すためだけに存在しているContentProviderな予感がしている。

Data Layer API

つまり、限定期間的な予定以外も見れるまともなカレンダーアプリを作りたければ、CalendarContractやWearableCalendarContractは機能的に不十分である。となると最後のやり方はData Layer APIを用いるしかない。実際にstackoverflowでもそのやり方を提案している人は多かった。

詳しい部分は作ってみないとわからないが、WearOSアプリと連携するモバイルアプリ側でCalendarContract経由でカレンダー情報を取得し、Data Layer APIを用いてWearOSアプリ側に連携することでWearableCalendarContractの制限を回避する、という話だと思われる。

推測ではあるが、WearOSアプリにあるカレンダーアプリとしてそこそこ有名なPro Wear Calendarもこのやり方を採用していそう。というのもPro Wear CalendarはWearOSに対応するモバイルアプリも併せて提供しており、モバイルアプリ側にWearOSアプリへ予定情報を同期する機能がある。おそらく、内部的にはData Layer APIを用いてWearOSにデータを同期しているのだろう。

結論

WearOSで動くカレンダーアプリを作りたいだけなのに、だいぶ面倒な気がする。他にもっといいやり方があるのか?

しかし、アプリストアをみるとWearOS用のカレンダーアプリはそんなにバリエーションがないので、やっぱりそういうことなのかなという気もする。

pretter/eslintのルール設定パッケージをひとつにまとめる理由

最近、eslint/prettierの設定を共通パッケージ(eg. xxx-prettier-config/xxx-esling-config)に切り出すタイミングがあった。

これに関して、巷ではxxx-prettier-configとxxx-eslint-configというような形でツールごとに個別のパッケージを用意するのが一般的かと思うが、個人的にはこのようなコーディングルールの自動化に関連するツールの設定ファイルは、分けずに敢えてひとつのパッケージにするほうがよいのではないかと思っている。

使われているのかは知らないが、たとえばSalesforceだとこういうのがある。

github.com

このパッケージはeslint, prettier, tsconfigなどのルール設定をひとつのパッケージにまとめている。

理由

端的にいうと、prettierとeslintで類似したルール設定にまつわる変更に一貫性を持たせることができる。

具体的な例として quote-props というルールを例に出し、以下のパッケージとバージョンがそれぞれ存在しているとする。

パッケージ バージョン 設定値
prettier-config v0 quoteProps: consistent
prettier-config v1 quoteProps: preserve
eslint-config v1 "quote-props": ["error", "consistent-as-needed"]

上記のケースではprettier-config v1とeslint-config v1を併用してもらえればquote-propsに関してはeslint側でconsistent-as-needeをルールとして強制することができる。

しかし、これがprettier-config v0とeslint-config v1の併用になってしまうと、eslintとprettierのどちらを先に適用するかでコーディングスタイルから一貫性が失われてしまうことになり、どのconfigをどの組み合わせで使うべきか意識する必要がある。

というわけで、以下のようにeslint/prettierのルール設定をcoding-styles-configとしてパッケージにしてバージョンニングすれば、変更がアトミックになるので嬉しい。

パッケージ バージョン 設定値
coding-styles-config v0 (prettier) quoteProps: consistent
coding-styles-config v1 (prettier) quoteProps: preserve
(eslint) "quote-props": ["error", "consistent-as-needed"]

peerDependenciesを利用する手もあるが、インストール時にエラーが出るのが体験として微妙なので、やはりこのようにモノリシックなパッケージである方が良い気がする。

余談

なおGoogleに至ってはprettier/eslintをラップしたCLIツールを内製しているらしく、さすがという感じ。

github.com

Next.jsアプリケーションのテスト方針覚書

現時点での自分の考えを雑なスナップショットとしてメモ

前提

  • ユニットテストに使うツールはjest(あるいはvitest)と@testing-library/reactを想定
  • テストに対応するモジュールを見つけやすいように __tests__ディレクトリは使わず、テスト対象と同じディレクトリに xxx.test.(ts|tsx) としてテストコードを配置する
  • 最低限のディレクトリ構成としてpages/components/hooksを用意し、必要に応じてlibなどのディレクトリを追加する
  • Next.jsなどフレームワークに対する依存を無理やり切り離そうとしない。
    • ほぼニコイチな存在なので逆に面倒なことになる。
    • テストしやすい(複雑なモックが必要ない)状態でメンテナブルなテストがたくさん書かれている方が重要。

Pages

  • hook/componentsを組み合わせて画面を実装する
    • データ取得や計算、変換などのロジックを直接ここには書かない
    • Next.jsに対する依存は可能な限りこのpageのみに限定する
  • ロジックがないことを前提にするので、pagesにはユニットテストを書かない
    • しかし本当に必要な場合のみ限定的にE2Eテストを書く
import { useRouter } from "next/router"
import { UsersView } from "@components/profile"

const Users: NextPage = () => {
  const router = useRouter()

  // onUserClickedはnext/routerに依存した実装
  const onUserClicked = useCallback((id) => {
    router.push(`/profile/${id}`)
  }, [router])

  return (
    <UsersView onUserClicked={onUserClicked} />
  )
}

Components

  • 画面を組み立てるためのコンポーネントを実装。原則ここにはすべてユニットテストを書く。
    • Atomicデザインはルールと実装の一貫性を維持するのが難しいため持ち込まない。
    • 状態遷移などのケースがないコンポーネントに対するテストの最小構成は@testing-library/reactのrender関数で実行時エラーなくレンダリングできるかことをチェックするだけでよい。
  • テスト時にimportモックが必要になるようなNext.js組み込みのモジュール(eg. next/navigation)には依存させない
    • 必要な場合にはコールバックなどで抽象的に依存させてpages側で依存を呼び出す関数を実装として注入する。
    • <Image><Link> などはモックしなくてよいので問題ない
import { useUsers } from "@hooks/user"

type Props = {
  // 利用するpageからrouter.pushによる遷移の実装の注入を期待したI/F
  onUserClicked: (id: string) => void
}

export const UsersView: React.FC<Props> = (props) => {
  const { users } = useUsers()

  return (
    <Container>
      {users.map((user) => (
         <UserContainer onClick={() => props.onUserClicked(user.id)}>
           <UserAvater user={user.image} />
           <UserName user={user.name} />
         </UserContainer>
       )}
    </Container>
  )
}

Hooks

  • データ取得や計算などコンポーネントから独立したpage/componentで必要なロジックを実装
    • 原則全てにユニットテストを書く。
    • 最小構成は renderHook によるhookの呼び出しで実行時エラーが出ないことのテスト。
  • componentと同様にNext.jsのモジュールには非依存の実装とする

React v18におけるCache API周りのコードを読む

React v18では以下のようにCache APIの関数をimportできるのだが、あまりに情報がないので2023年の現時点でのコードを少し読んでみる。

Reactのコミットヒストリを見る限りCache APIは2022年10月ごろにmainへマージされたらしい

github.com

Cache, CacheContext, createCache

まずは cache が呼ばれた際に、そのデータがどこに格納されるのかを知りたい。

少し読んでみると CacheContext という名前で以下のコードが見つかる。

export type Cache = {
  controller: AbortController,
  data: Map<() => mixed, mixed>,
  refCount: number,
};

// ...

export const CacheContext: ReactContext<Cache> = enableCache
  ? {
      $$typeof: REACT_CONTEXT_TYPE,
      // We don't use Consumer/Provider for Cache components. So we'll cheat.
      Consumer: (null: any),
      Provider: (null: any),
      // We'll initialize these at the root.
      _currentValue: (null: any),
      _currentValue2: (null: any),
      _threadCount: 0,
      _defaultValue: (null: any),
      _globalName: (null: any),
    }
  : (null: any);

// ...

// Creates a new empty Cache instance with a ref-count of 0. The caller is responsible
// for retaining the cache once it is in use (retainCache), and releasing the cache
// once it is no longer needed (releaseCache).
export function createCache(): Cache {
  if (!enableCache) {
    return (null: any);
  }
  const cache: Cache = {
    controller: new AbortControllerLocal(),
    data: new Map(),
    refCount: 0,
  };

  return cache;
}

上記のコードのうちCache型のフィールドにある data というMap型の値が、実際にキャッシュを保持していると思われる。

Reactアプリケーションのライフサイクルの中ではルートのFiber生成時に初期化が行われるよう。あとはReactFiberTransitionと呼ばれるモジュールの中でキャッシュプールを生成する際にも使われているようだが、ここは複雑すぎてよく分からなかった。

なお、上記の ReactContext<T> はReact Contextそのものであり、内部的なインメモリキャッシュの実装もReact Contextを用いて行われていることが分かる。

getCacheForType

createCache で生成されたキャッシュはMapをキャッシュデータとして保持するが、実際にほかのモジュールがキャッシュの値を参照するにあたっては getCacheForType という関数を経由する。

// ...

function getCacheForType<T>(resourceType: () => T): T {
  if (!enableCache) {
    throw new Error('Not implemented.');
  }
  const cache: Cache = readContext(CacheContext);
  let cacheForType: T | void = (cache.data.get(resourceType): any);
  if (cacheForType === undefined) {
    cacheForType = resourceType();
    cache.data.set(resourceType, cacheForType);
  }
  return cacheForType;
}

export const DefaultCacheDispatcher: CacheDispatcher = {
  getCacheSignal,
  getCacheForType,
};

この関数は、Reactにおけるキャッシュが必要なあらゆる機能群で使われることが想定されているようである。

例えばReactによるfetch関数の拡張*1にも使われいるし、ユーザーレベルにも unstable_getCacheForType としてexportされている。

cache

さて、とうとう核心部分であるが、以下が cache の関数の実装になる。

getCacheForType のレシーバとなっている dispatcher という変数は、上記で触れた CacheDispatcher インターフェイスを実装したもの。

export function cache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
  return function () {
    const dispatcher = ReactCurrentCache.current;
    if (!dispatcher) {
      // If there is no dispatcher, then we treat this as not being cached.
      // $FlowFixMe[incompatible-call]: We don't want to use rest arguments since we transpile the code.
      return fn.apply(null, arguments);
    }
    const fnMap: WeakMap<any, CacheNode<T>> = dispatcher.getCacheForType(
      createCacheRoot,
    );
    const fnNode = fnMap.get(fn);
    let cacheNode: CacheNode<T>;
    if (fnNode === undefined) {
      cacheNode = createCacheNode();
      fnMap.set(fn, cacheNode);
    } else {
      cacheNode = fnNode;
    }
    for (let i = 0, l = arguments.length; i < l; i++) {
      const arg = arguments[i];
      if (
        typeof arg === 'function' ||
        (typeof arg === 'object' && arg !== null)
      ) {
        // Objects go into a WeakMap
        let objectCache = cacheNode.o;
        if (objectCache === null) {
          cacheNode.o = objectCache = new WeakMap();
        }
        const objectNode = objectCache.get(arg);
        if (objectNode === undefined) {
          cacheNode = createCacheNode();
          objectCache.set(arg, cacheNode);
        } else {
          cacheNode = objectNode;
        }
      } else {
        // Primitives go into a regular Map
        let primitiveCache = cacheNode.p;
        if (primitiveCache === null) {
          cacheNode.p = primitiveCache = new Map();
        }
        const primitiveNode = primitiveCache.get(arg);
        if (primitiveNode === undefined) {
          cacheNode = createCacheNode();
          primitiveCache.set(arg, cacheNode);
        } else {
          cacheNode = primitiveNode;
        }
      }
    }
    if (cacheNode.s === TERMINATED) {
      return cacheNode.v;
    }
    if (cacheNode.s === ERRORED) {
      throw cacheNode.v;
    }
    try {
      // $FlowFixMe[incompatible-call]: We don't want to use rest arguments since we transpile the code.
      const result = fn.apply(null, arguments);
      const terminatedNode: TerminatedCacheNode<T> = (cacheNode: any);
      terminatedNode.s = TERMINATED;
      terminatedNode.v = result;
      return result;
    } catch (error) {
      // We store the first error that's thrown and rethrow it.
      const erroredNode: ErroredCacheNode<T> = (cacheNode: any);
      erroredNode.s = ERRORED;
      erroredNode.v = error;
      throw error;
    }
  };
}

細かい説明は面倒なので省くが、ざっくり引数で与えられた関数の arguments をキーにして、再帰的にネストするツリー構造のような形の CacheNode と呼ばれる型の値を生成している。

すでに関数が実行済み (CacheNode.s === TERMINATED) であれば CacheNode.v に保存されている値を返す。

自分はあまりキャッシュアルゴリズムなどの理論に詳しくないため、なぜこのようなキャッシュの構造になっているのかは実装されたPRのdescriptionを読んでもよく分からなかった...

refreshCache

cache 関数の実装は分かったので、次に unstable_useCacheRefresh のほうの実装を見ていく。

いきなり実装から行くが、以下のコードがキャッシュのinvalidationを行うものになる。

unstable_useCacheRefresh はhookであるため、hookを呼び出したコンポーネントの所属するCacheContextが createCache によって初期化される。

function refreshCache<T>(fiber: Fiber, seedKey: ?() => T, seedValue: T): void {
  if (!enableCache) {
    return;
  }
  // TODO: Does Cache work in legacy mode? Should decide and write a test.
  // TODO: Consider warning if the refresh is at discrete priority, or if we
  // otherwise suspect that it wasn't batched properly.
  let provider = fiber.return;
  while (provider !== null) {
    switch (provider.tag) {
      case CacheComponent:
      case HostRoot: {
        // Schedule an update on the cache boundary to trigger a refresh.
        const lane = requestUpdateLane(provider);
        const refreshUpdate = createLegacyQueueUpdate(lane);
        const root = enqueueLegacyQueueUpdate(provider, refreshUpdate, lane);
        if (root !== null) {
          scheduleUpdateOnFiber(root, provider, lane);
          entangleLegacyQueueTransitions(root, provider, lane);
        }

        // TODO: If a refresh never commits, the new cache created here must be
        // released. A simple case is start refreshing a cache boundary, but then
        // unmount that boundary before the refresh completes.
        const seededCache = createCache();
        if (seedKey !== null && seedKey !== undefined && root !== null) {
          if (enableLegacyCache) {
            // Seed the cache with the value passed by the caller. This could be
            // from a server mutation, or it could be a streaming response.
            seededCache.data.set(seedKey, seedValue);
          } else {
            if (__DEV__) {
              console.error(
                'The seed argument is not enabled outside experimental channels.',
              );
            }
          }
        }

        const payload = {
          cache: seededCache,
        };
        refreshUpdate.payload = payload;
        return;
      }
    }
    provider = provider.return;
  }
  // TODO: Warn if unmounted?
}

ReactCacheのテストコードを読むとこのあたりの挙動はわかりやすい。

おそらく Suspense がひとつのCache boundaryという扱いだと思われる。

  test('refresh a cache boundary', async () => {
    let refresh;
    function App() {
      refresh = useCacheRefresh();
      return <AsyncText showVersion={true} text="A" />;
    }

    // Mount initial data
    const root = ReactNoop.createRoot();
    await act(() => {
      root.render(
        <Suspense fallback={<Text text="Loading..." />}>
          <App />
        </Suspense>,
      );
    });
    assertLog(['Cache miss! [A]', 'Loading...']);
    expect(root).toMatchRenderedOutput('Loading...');

    await act(() => {
      resolveMostRecentTextCache('A');
    });
    assertLog(['A [v1]']);
    expect(root).toMatchRenderedOutput('A [v1]');

    // Refresh for new data.
    await act(() => {
      startTransition(() => refresh());
    });
    assertLog(['Cache miss! [A]', 'Loading...']);
    expect(root).toMatchRenderedOutput('A [v1]');

    await act(() => {
      resolveMostRecentTextCache('A');
    });
    // Note that the version has updated
    if (getCacheSignal) {
      assertLog(['A [v2]', 'Cache cleanup: A [v1]']);
    } else {
      assertLog(['A [v2]']);
    }
    expect(root).toMatchRenderedOutput('A [v2]');

    await act(() => {
      root.render('Bye');
    });
    expect(root).toMatchRenderedOutput('Bye');
  });

こういうのはやはりテストコードを読むといい。

今回の学び

  • 近い将来Reactに fetch をCache APIでラップした実装が入る。Canaryなら今すぐ使える。
    • これが入ればfetchに関してはUse APIと組み合わせてすぐにSuspenseなコンポーネントに使うことができるのですごく嬉しい。
  • CacheのinvalidationはCache boundaryやSuspenseなどの単位で行われる。
    • Next.jsのServer-sideで用意されているような、任意のCacheキーを指定して選択的にboundaryを横断してinvalidationするようなことは現状できなさそう。
  • 今回読んだCache APIにまつわる詳しいscrapboxの記事があった → Built-in Suspense Cache APIをどう使うか - tosuke
    • TypeScriptで使うにあたっては @types/react で定義されていないものがちらほらあるが、Reactのコードでexportを見るとunstable なprefixでimportはできるっぽい。

*1:現時点で最新のv18.2.0には含まれていない