今更type-challengesをやる -初級編②-

type-challengesを解いていくやつ第2回。今回は初級編の続きです。
前回はこちら。

karakasadcfd.hatenablog.com

If

3つの型引数C,T,Fを受け取り、CtrueならTを、CfalseならFを返す型Ifを実装してみようという問題。
単純に型Ctrueなのかfalseなのか判別できればいいが、型の世界の話なのでどう記述するかがポイントっぽい。
このような型による条件分岐をしたい時はConditional Typesと呼ばれるものを使えばいい。

www.typescriptlang.org

通常の三項演算子における条件文でextendsを使用するだけなので、知っていればなんということはなかった。

type If<C extends boolean, T, F> = C extends true ? T : F

Awaited

Promise<T>を受け取ってTを取り出して返す型を実装しようという問題。
Promise<T>からTを取り出すという部分をどう記述するかがポイントっぽい。
これにはinferを使えば良さそう。inferはConditional Typesのextends節の中で使用することで型を推論することができ、推論した型を型引数として導入できる。この機能については公式ドキュメントのAdvanced Typesの中で述べられている。

www.typescriptlang.org

今回の問題の場合はT extends Promise<infer R>のような形で記述すればTがなんらかのPromiseであるときに、それが内包する具体的な型を型変数Rとして扱うことができるようになる。

type MyAwaited<T extends Promise<any>> = T extends Promise<infer R> ? R : never

inferは色々悪さができそうなので、今後の問題でもめちゃくちゃ使いそうな予感。

Concat

Array.concatの型バージョンを実装しようという問題。
前回のPushやUnshiftと同様、Variadic Tuple Typesを使うだけ。

www.typescriptlang.org

type Concat<T extends any[], U extends any[]> = [...T, ...U]

First of Array

配列Tを受け取り、その最初のプロパティの型を返すFirst<T>を実装しようという問題。
前回も活躍したインデックスアクセス型を使えば、配列の最初のプロパティの型はT[0]と書くだけで取り出せそう。

typescriptbook.jp

しかし空配列を渡された場合はこのようなプロパティが存在しないためエラーとなるので、空配列か空でない配列かで条件分岐する必要があるのがミソっぽい。

type First<T extends any[]> = T extends [] ? never : T[0]

Includes

Array.includeの型バージョンを実装しようという問題。
配列の中に特定の要素が含まれているかを判定するために、まず配列の要素の型をユニオン型で取得したい気持ちになる。これは前回Tuple to Objectでも同じような気持ちになったので、今回もインデックスアクセス型が活躍する。前の問題(First of Array)のようにT[0]と具体的な数値を指定するのではなく、T[number]のように記述することで要素の型をユニオン型で取得できる。

type Includes<T extends readonly any[], U> = U extends T[number] ? true : false

Parameters

TypeScript組み込みのユーティリティ型であるParameters<T>を自分で実装してみようという問題。
Parameters<T>は受け取った関数型の引数の型をタプルで返す型なので、渡された関数の型から引数部分だけをどうにか取り出す必要がありそう。ある型が内包する具体的な型を取得したいといえばAwaitedの問題でも同じ気持ちになったので、ここでもinferに頑張ってもらう。

type MyParameters<T extends (...args: any[]) => any> = T extends (...args: infer R) => any ? R : never

ここまでの感想

今回はConditional Typesのチュートリアル的な感じなのか、型の条件分岐を必要とする問題が中心の構成だった。さらにinferも登場して、今後の問題でもたくさん活躍してくれそうな武器が手に入った回だった。
次は中級編の方に入っていこうと思う。

ところで......

色々見ているうちに気付いたのだが、どうやら本家type-challengesが提供するPlaygroundでは各問題に対して様々なテストケースが用意されており、随時追加されたりもしているらしい。ということで今まで解いた問題をチェックし直してみたが、なんとまあテストに落ちること落ちること......。やばそうなテストケースもあり、easyとはなんだったのかという気持ちが増してくる。
というわけで、次回は中級編ではなく初級編の完全解答を目指す回になりそう。(それもしかして中級編解くよりも難しかったりしないか......?)

今更type-challengesをやる -初級編①-

業務でTypeScriptを使った開発をしているのだが、複雑な型を扱いたい時に「TypeScriptへの型の理解が足りないなあ」と感じることが何度かあった。「そういえばtype-challengesとかやってないなあ」ということに思い至ったので、今更ながらにtype-challangesをやってみる。
各問題の詳しい解説などはいくらでも先行事例があると思われるので、ここでは個人的な感想を書いていくことにする。
type-challengesを解くのにあたっては、mosya<TC>を利用した。

github.com

Readonly

TypeScript組み込みのユーティリティ型であるReadonly<T>を自分で実装してみようという問題。
結論から言えば、これはkeyof型演算子とMapped Typesの組み合わせによって表現することができる。

typescriptbook.jp
typescriptbook.jp

keyof型演算子とMapped Typesの組み合わせは頻出であり、Readonly<T>がこれらの組み合わせで実装されていることも、サバイバルTypeScriptを読んでいたときに認知していたので難しくはなかった。

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P]
}

Length of Tuple

タプルTを受け取って、その長さを返す型Length<T>を実装しようという問題。
これには少々面食らってしまった。というのも、型の操作だけで長さを導く方法なんて検討もつかなかったからだ。
しかし冷静に考えてみると、あるタプルの長さを取り出したい時はsomething.lengthのように、lengthプロパティを使用して長さを取り出している。言い換えるとこれはつまり、タプルにはlengthという名前のプロパティが存在しているという意味に他ならない。ということは、タプルが持つlengthというプロパティの型を知ることができればこの問題は解けそうである。
TypeScriptではインデックスアクセス型によってオブジェクトの特定のプロパティの型を参照できることを踏まえれば、あとはもう簡単だった。

typescriptbook.jp

type Length<T extends readonly any[]> = T['length']

公式ドキュメントでも、TypeScriptにおけるタプルが特定のインデックスに対してプロパティを宣言し、数値リテラル型によって長さを宣言するようなArrayと見做せることについて言及されている。

www.typescriptlang.org

Tuple to Object

タプルを受け取り、その各値のkey/valueを持つオブジェクトの型に変換する型を実装しようという問題。
これもMapped Typesを使って解くことができそうだが、Readonlyと違って今回はkeyof型演算子を使えないのがミソっぽい。タプル型の値から要素の型を抽出する方法だが、ここでもインデックスアクセス型が活躍する。

typescriptbook.jp

落とし穴として、今回は受け取ったタプルの要素がオブジェクトのキーとなるので、任意のタプルを受け取れるようにするとエラーが発生するケースがある。受け取れるタプルの要素の型をPropertyKey型で制約しておけば問題なさそう。

type TupleToObject<T extends readonly PropertyKey[]> = {
  [P in T[number]]: P
}

Push

Array.pushの型バージョンを実装しようという問題。
型でもスプレッド構文が使えることを知っていれば特筆すべきことはなさそう?

type Push<T extends any[], U> = [...T, U]

厳密にはこの型バージョンのスプレッド構文のようなものはTypeScript 4.0で実装されたもので、Variadic Tuple Typesと呼ばれるらしい。

www.typescriptlang.org

Unshift

Array.unshiftの型バージョンを実装しようという問題。
Pushと同じことをやるだけ。

type Unshift<T extends any[], U> = [U, ...T]

Pick

TypeScript組み込みのユーティリティ型であるPick<T, K>を自分で実装してみようという問題。
これもMapped Typesを使ってあげれば難なく書けそう。

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}

インデックスアクセス型であるT[P]の部分ではTに存在しないキーを指定することはできないので、あらかじめKの型をTのキーで制約しておく必要があるのがポイントか。

ここまでの感想

初級編と言いながら、結構考えさせられる問題が多い印象。どれもTypeScriptの基本的な機能を使って解けるとはいえ、その基本部分をしっかり押さえていないと手も足も出なさそう。

ISUCON13に参加しました

こんいす〜。ISUCON13に参加したので、参考になるかはわかりませんが当日考えたことややったことについて書いていきたいと思います。

今回の問題

今回の問題はライブ配信サービス「ISUPipe」の高速化でした。
スコア計算が「投げ銭機能により送金・寄付された金額の合計」と非常にシンプルでわかりやすかったのが面白かったです。
www.youtube.com

当日の戦略

マニュアルを見て今回の問題はアイコン画像の配信とDNSサーバーが最初の大きな課題になると思いました。DNSサーバーについては残念ながら詳しくなかったので、早速色々と調べてみたところ、今回の問題の出題企業であるさくらインターネット株式会社様の発表記事を見つけることができました。
knowledge.sakura.ad.jp
この発表は今年の1月に行われた比較的新しいものであることや、今回の問題で使用されているPowerDNSに対するDNS水責め攻撃への対策について述べられていることから、これが問題の元ネタなんじゃないかと考えました。この記事から「dnsdistというDNS用のプロキシサーバーがあるらしい」ということ、そして「PowerDNSのバックエンドとしてMySQLのようなRDBMSを使用するのは水責め攻撃時のパフォーマンス影響が大きいため、KVSやBIND形式を採用する方が良いらしい」ということがわかりました。今回の問題の初期実装ではPowerDNSのバックエンドがMySQLだったので、これを移行することが重要課題であると認識できました。
ここまでのことを踏まえて、当日はまず アイコン画像配信担当 / DNS担当 / アプリケーションのN+1を潰す担当 で別れて作業を進めることにしました。自分はN+1を潰す人だったので、どんなことをやったのか次で書いていこうと思います。

やったこと

以降の内容はGo言語での実装について話しています。

ライブ配信の検索を改善する1

ライブ配信の検索処理を行うsearchLivestreamsHandlerではタグによる検索を行った場合、まずそのタグが付けられたライブ配信のID一覧をDBから取得し、各IDに対して1つずつライブ配信の情報をDBから取得するという典型的なN+1が発生しています。これを解決するにはSQLのIN句を用いて、全てのIDに対するライブ配信の情報を一度に取得すれば良さそうです。
しかし、今回のケースでは1つだけ落とし穴があります。というのもここでは、ライブ配信のIDの降順に結果を返すことを期待されているということです。IN句による単純な取得では、IN句に指定したIDの順番を考慮してくれません。そのため、このままではレスポンスが不正であると怒られてしまいます。
これを解決する方法はいくつかあってSQLで解決する方法もあるらしいのですが、今回は素直にアプリケーション側でソートをすることにしました。Go言語であればsort.Sliceを使うことでIDの降順ソートを実現できます。
またアプリケーション側でソートすることによって、livestream_tagsテーブルからライブ配信IDの一覧を取ってくる際にソートをする必要がなくなるので、ORDER BY livestream_id DESCを消すことができるというメリットもあります。これにより、テーブルにINDEXを張る時に複合インデックスにしなくても良くなったりします。

ライブ配信の検索を改善する2

searchLivestreamsHandlerにはもう1つN+1が仕込まれており、それはDBから取得したライブ配信データ(LivestreamModel型)を実際に返すレスポンスデータ(Livestream型)として整形する部分で発生します。このLivestreamModel型からLivestream型への変換はfillLivestreamResponseという関数を用いて行われていますが、この関数が単一のLivestreamModel型のデータを変換することにしか対応していないため、複数のLivestreamModel型のデータを変換したい場合には必然的にN+1が発生してしまいます。
実はこのような問題はアプリケーションの至るところで発生しており、XxxModel型をXxx型に変換するfillXxxResponse関数を、同時に複数のデータを変換できるように対応させた関数に置き換えることができるかというのが最初の大きな課題のようでした。
ひとまずfillLivestreamResponseの実装を見てみると、レスポンスに詰め込む情報を取得するために様々なクエリが発行されており、ループの中で呼び出すのはまずそうだということがわかりました。しかもこのfillLivestreamResponseの中の処理でも、ライブ配信に付けられたタグのデータをレスポンスデータにする部分でN+1が仕込まれていました。とりあえずこの部分はすぐに直すことにしましたが、fillLivestreamResponse自体の複数対応は面倒そうだったので、一旦見送ることにしました。

ライブコメントの取得を改善する

計測の結果、次にライブコメントの取得が重そうだったので実装を見てみたのですが、なんとパッと見で重そうな処理がfillLivecommentResponseの複数回呼び出しによるN+1しかなかったので、覚悟を決めて向き合うことにしました。
fillLivecommentResponseを複数データ変換に対応させるためには、まずfillUserResponsefillLivestreamResponseを複数データ変換に対応させる必要があります。fillUserResponseの複数データ変換対応が一番簡単で、この実装ができれば後のやつらは基本的にこれを真似て複数データ変換対応させていけば良かったです。
基本的にはSQLのIN句を用いたデータの一括取得を行えばいいのですが、例えばユーザーを例にするとアイコン画像を一括取得しただけの状態では、あるユーザーに紐づいたアイコン画像の情報が取得した配列のどこに存在するかわからないので、すぐに取り出すことができません。そこでユーザーのIDをKeyとし、アイコン画像のデータをValueとするような連想配列を用意する前処理を行うことで、後でデータを取り出しやすくなります。
fillLivestreamResponseの方は、この連想配列を用意する前処理を行う部分が若干面倒くさかったので、TODOコメントだけ置いて後でまとめてfillLivestreamResponseをループ内で呼び出している箇所を置き換えることにしました。

リアクションの取得を改善する

リアクションの取得も重そうだったので実装を見ましたが、こちらもライブコメントの取得と同じくfillReactionResponseの複数回呼び出しによるN+1が原因のようでした。fillReactionResponseの実装もfillLivecommentResponseとほぼ同じなので、さっきやったことをやるだけです。

fillLivestreamResponseをなんとかする

ここまで来てようやくfillLivestreamResponseの複数データ変換対応をすることにしました。といっても、これまでやってきたことと同様のことをやるだけなので、特筆するべきことはないです。fillLivestreamResponseをループ内で呼び出している場所は結構あったので、この改修はかなり効果があったと思われます。
ここまでやると、スコアの方もそこそこ伸びてきました。

ライブコメント投稿時のスパム判定を改善する

ライブコメントが投稿されたとき、postLivecommentHandlerでは投稿されたライブコメントがNGワードを含んでいないか判定しているのですが、初期実装ではこの判定をSQLでなんとかしようとしているため効率が悪いです。NGワードを含んでいるかの判定はアプリケーション側で簡単に行うことができ、例えばGo言語ならstrings.Containsを使うことで容易に判定することが可能です。
NGワードを何回含んでいるかをカウントするhitSpam変数もログの出力に使われているくらいで、実際にはNGワードが1つ以上含まれているかどうかだけを知ることができればいいので、strings.Containsで十分というわけです。

ユーザーの統計情報を改善する

getUserStatisticsHandlerではユーザーがこれまでに行なった配信で獲得した累計のライブコメント数や売上金額などの統計情報を計算し、レスポンスとして返しています。しかしこれらの統計情報の計算を、ページにアクセスがあるたびに1から全て頑張って計算しているため、大量のDBアクセスが発生し非常に重たい処理となっていました。
このような場合の解決策としては、各種統計情報を格納するカラムをusersテーブルなどに用意し、統計情報が欲しいときはカラムの値を取り出すだけで済むようにしてしまうことです。例えばユーザーの累計のライブコメント数ならば、usersテーブルにtotal_commentsというカラムをデフォルト値0で用意します。このカラムの更新はそのユーザーの配信に対してコメントが増えたとき(livecommentsテーブルにINSERTが走ったとき)と、新たなNGワード登録などによってコメントが削除されたとき(livecommentsテーブルにDELETEが走ったとき)に発生します。
このような更新処理は、アプリケーション側でINSERTやDELETEのSQLが発行される場所で、このカラムの値をUPDATEする処理を記述する方法も考えられますが、初期データ投入時のデータ不整合を防ぐための対応も必要となるので面倒です。なので自分はトリガーの機能を用いてINSERTやDELETEを検知して更新を走らせるようにしましたが、結構雑にトリガーを書きまくったのでこれが正解なのかはあまり自信がないです。もっといいやり方があったら知りたい。
このように統計情報をカラムに載せるようにしていくと、ユーザーのランキング計算も2つのカラムの値の和を計算するだけで済むので非常に効率的になりました。

一方その頃

さて、ここまでアプリケーションのN+1撲滅活動について述べてきましたが、アイコン画像配信とDNSの方は非常に難航していました。
イコン画像配信の方では実装がほぼ完了したものの、原因のわからないエラーに苦しめられていました。後で詳しく話を聞いてみると、アイコン画像の更新が入る前にアイコンの取得処理が走ると、返すべきハッシュがなく死ぬらしいということだったのですが、実際にはアイコン画像が設定されていないユーザーはデフォルトの画像としてNoImage.jpgが返されているので、これに対応するハッシュを返せば済む話でした。初期アイコンの存在についてはN+1の改善を行なっている中で自分は認識していたので、もっと早い段階で問題の内容について詳しく聞くことができていれば良かったと反省しています。
DNSの方はdnsdistを挟む作業を無事に終えた後、上述した記事を元にBIND形式への移行を目指して取り組み始めたのですが、静的なファイルで設定を行うBIND形式では今回のようにサブドメインが動的に増えていくようなサービスの要件には合わない、という事実に気付くまでにかなりの時間を要してしまいました。このことに気付いてからはKVS型のバックエンドへの移行を目指しましたが、これはすぐに移行を完了させることができたので、BIND形式で悩ませてしまった時間が非常にもったいなかったです。結果的に名前解決の成功件数を爆増させることに成功したものの、dnsdistを用いて水責め攻撃への根本的な対策をする時間がなく、大きなスコアの伸びに繋げられなかったので悔しいです。
競技終了直前まではサーバー3台構成への移行作業を進めていたのですがこれもうまくいかず、1台構成のまま最終提出を行おうとしたのですが、移行作業中にGitHubの管理下に置いていなかったenv.shを編集していたことを忘れていたためFAILし、最終結果は0点となりました。皆さんもGitHubの管理下にないファイルを触るのはやめましょう。

感想

全体としては非常に悔しい結果となってしまいました。初手での考察や分担などの動きは悪くなかったのではないかと思うのですが、その後の実装部分での連携不足によって本質的な問題の解決を終わらせられず、貴重な人的資源も大きく消費してしまったのがやはり大きいでしょう。ここを乗り越えていれば上位のスコアも狙えたと思うだけに、本当に悔しいです。次の機会があればリベンジしたいと思います。
まだまだやりたかったこともたくさんあるので、とりあえず感想戦ができるようになったらバリバリやっていきたいです。

研修で作ったアプリケーションのリファクタリングをした話

社内の研修でNext.jsを使ったWebアプリケーション開発を行なった。研修当時は短期間で完成させる必要もあり、かなりコードがおざなりになってしまっていた。また、新しくなったReactの公式ドキュメントに一通り目を通し始めたこともあり、タイミングを見て簡単なリファクタリングを試みることにした。

react.dev

ここでは、リファクタリングの中で得た知見について書き留めていくことにする。とはいっても、巨大なコンポーネントの分割だとかについて述べてもしょうがないので、ReactだったりJavaScriptだったりの言語的な使用に関連したトピックについて触れていきたい。

アプリケーションの概要

本題の前にアプリケーションの概要を述べておくと、簡潔にいえばテキスト投稿サービスである。投稿には返信をすることもできる。また、画像や文字修飾によって投稿をデコレーションすることもできるが、今回の話ではあまり関係してこない。

矛盾するようなstateを管理しない

開発したアプリケーションでは投稿を表示するためのPostコンポーネントがあるのだが、これはユーザーの操作によって見た目が変わる。具体的には投稿の編集ボタンを押した時は編集用のフォームが出現するし、投稿に返信するボタンを押した時は返信用のフォームが出現する。また、投稿自体が削除されている場合は特別なコンポーネントを表示するようにしている。

これらを実現するために、Postコンポーネントでは次のような複数のstateを管理していた。

const [isEditing, setIsEditing] = useState(false);
const [isEditingReply, setIsEditingReply] = useState(false);
const [isDelete, setIsDelete] = useState(post.isDeleted);

しかし、このような実装はstate管理やロジックを複雑にする原因になる。

今回のケースでは、これらのstateが複数同時にtrueになることはないはずである。投稿の編集と投稿への返信は同時には実行されないし、投稿が削除された状態で投稿の編集や投稿への返信が行えるのは明らかにおかしい。これはつまり、例えば編集ボタンが押された時にisEditingをtrueにしようと思ったら、そこでは同時にisEditingReplyisDeleteもfalseにして、コンポーネントの状態が衝突しないことを保証しなくてはいけないということである。

このように同時に存在することがない複数の状態を矛盾なく管理したいような場合は、次のように単一のstateでコンポーネントの状態を管理する方が良い。

type PostStatus = "show" | "edit" | "reply" | "delete";

const [status, setStatus] = useState<PostStatus>(post.isDeleted ? "delete" : "show");

const isEditing = status === "edit";
const isEditingReply = status === "reply";
const isDelete = status === "delete";

statusはこのコンポーネントの現在の状態を表す文字列を保持するstateである。コンポーネントの初期状態はshowで表すことにする。statusが任意の文字列を受け取れると困るので、ユニオン型を利用することで受け取れる文字列を制限する。

このようにすればコンポーネントの状態が矛盾することはなく、各状態への更新もsetStatusを一度呼び出すだけで済む。

参考: Choosing the State Structure – React

キーの順序に依存する処理を書きたいときは、オブジェクトではなくMapを使う

投稿の一覧を表示する場面では、日付毎に投稿を表示したい。これを実現するために、次のような処理を行っていた。

const postsOrderByDate: Record<string, PostWithChildPosts[]> = {};
posts.forEach((post) => {
  const date = new Date(post.createdAt);
  const dateStr = date.toLocaleDateString();
  if (!postsOrderByDate[dateStr]) {
    postsOrderByDate[dateStr] = [];
  }
  postsOrderByDate[dateStr].push(post);
});

このコードでは投稿の配列postsを元に、日付を表す文字列をキーとして、その日付に作成された投稿の配列を得ることができるようなオブジェクトを作成している。ここで作成されたオブジェクトは、次のようにObject.keys()メソッドを用いてJSX.Elementの配列を作成するのに使用されている。

const postsWithDate = Object.keys(postsOrderByDate).map((key, _) => {
  return (
    <div key={`date:${key}`}>
      <DateHeader date={key} />
      <PostList
        posts={postsOrderByDate[key]}
        updatePostList={updatePostList}
      />
    </div>
  );
});

しかし、この処理ではObject.keys()が返すキーの順序に無頓着であるにも関わらず、開発者としては先のpostsOrderByDateオブジェクトに挿入した順序でキーの順序が保たれることを期待している。より厳密には、元となる投稿の配列postsは既に作成日時で降順ソートされているという前提で、postsOrderByDateおよびpostsWithDateでは日時の降順にデータが保たれていることを期待している。

しかしながら、JavaScriptのオブジェクトは公式ドキュメントでも記載されている通り、一般的にはプロパティの順序が保証されないものとして扱うべきである。ECMAScript 2015以降ではプロパティの列挙順をある程度で保証するような仕様変更がなされたとはいえ、様々な落とし穴もあるため好ましくはない。

このような場合は、Mapオブジェクトを使用することが推奨されている。Mapオブジェクトでは挿入順でキーを返すことが保証されているため、キーの順序に依存する処理を安全に書くことができる。

const dateToPosts = new Map<string, PostWithChildPosts[]>();
posts.forEach((post) => {
  const date = new Date(post.createdAt);
  const dateStr = date.toLocaleDateString();
  const now = dateToPosts.get(dateStr) ?? [];
  now.push(post);
  dateToPosts.set(dateStr, now);
});

参考: Map - JavaScript | MDN

その他の落とし穴

useEffect が2回実行される

Reactではコンポーネントが最初にマウントされたタイミングで一度だけ実行したいような処理を書くとき、依存配列が空なuseEffect()を使用することがある。例えばトップページにアクセスした時に、最新の投稿一覧を表示したい場合などがそうである。

useEffect(() => {
  async function startFetching() {
    const json = await fetchCurrentPosts();
    setPosts(json);
  }

  startFetching();
}, []);

しかし、Reactは開発環境においてStrictModeが有効な場合に、すべてのコンポーネントを最初にマウントした直後に一度だけ再マウントする、という挙動をとる。そのため、開発環境では上記のようなuseEffect()が2回続けて実行されることがある。

これによる不都合を回避する方法は、クリーンアップ関数を実装してあげることである。addEventListener()に対するremoveEventListener()のようにわかりやすい場合はいいのだが、明確なクリーンアップ関数が存在しない場合は工夫が必要だ。例えば上記のデータフェッチのような場合は、次のようなignore変数を用いることで、以前のマウント時に発行されたリクエストが後のマウント時のstateに影響を与えないようにすることが可能になる。

useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchCurrentPosts();
    if (!ignore) {
      setPosts(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, []);

このテクニックは、もちろん依存配列が空でない場合にも有効である。

参考: Synchronizing with Effects – React

useCallback は memo と一緒に使う

コンポーネントの不必要な再レンダリングを避けたくて、次のようなコードを書くことがある。

function ProductPage({ productId, referrer, theme }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

  return (
    <div className={theme}>
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );

JavaScriptでは関数をはじめとするオブジェクトの等価性は参照によって比較されるため、単にhandleSubmitコンポーネント内で定義するだけでは、再レンダリング前後では別物として扱われてしまう。これを避けるためには、レンダリング前後でメモ化された関数を返すことが可能なuseCallback()を使うことが有効である。

例えば上記の例では、親コンポーネントから渡されるthemepropsの値が変わっても、それを参照しないhandleSubmitには何も影響がないはずなので、useCallback()でメモ化することでthemepropsの値が変わった後も同一のものとして扱うことができる。これはつまり、ShippingFormコンポーネントに渡されているpropsがProductPageコンポーネントレンダリング前後で同一と言うことなので、一見するとShippingFormコンポーネントの再レンダリングを防げるかのように思われる。

しかしながら、デフォルトではReactはコンポーネントが再レンダリングされた際には、その子コンポーネントも全て再帰的に再レンダリングしようとしてしまう。よって、このままではShippingFormコンポーネントの再レンダリングを防げてはいないのだ。

themepropsの変更によるShippingFormコンポーネントの再レンダリングを妨げるには、ShippingFormコンポーネントmemoでラップする必要がある。

const ShippingForm = memo(function ShippingForm({ onSubmit }) {
  // ...
});

なぜかこの仕様を忘れてしまうので気をつけたい......。

参考: useCallback – React

はてなリモートインターンシップ2021に参加しました

id:KarakasaDcFd です。2021/8/16より三週間に渡って開催されていたはてなインターン2021に参加してきましたので、その参加記を書かせていただこうと思います。今回のはてなインターンは前半一週間が講義パート、後半二週間が実践パートという構成でした。

f:id:KarakasaDcFd:20210906170556p:plain

前半

前半パートではWebサービスの開発や運用に関わる様々な内容の講義が行われました。自分自身、既に知っている内容もありつつ初めて触れる内容も多々あり、盛りだくさんの内容でした。

自分はインフラ周りを苦手としていることもあり、kubernetesの講義は非常に興味深かったです。AWSから講師の方が来てくださって、ハンズオン形式でAWSについて学ぶことができたのも良かったです。また技術的な講義以外にも、企画の講義では企業におけるWebサービス開発全体の流れを学ぶことができ、実践パートでも活かせたんじゃないかなと思います。

後半

後半パートではインターン生が四つのチームに分かれて開発を行いました。自分は id:sken11 さんと一緒にノベルチームに配属されました。ノベルチームは我々が記念すべきインターン一期生ということでめでたいですね。

ノベルチームでは大手出版社のKADOKAWAさんとはてなさんが共同開発しているWeb小説投稿サイト「カクヨム」の開発に携わりました。自分自身、普段からカクヨムをはじめとするWeb小説投稿サイトを利用するユーザーの一人なので、この実践パートはかなり楽しみにしていました。開発では新機能を自分たちで考えて実装し、実際にリリースするところまでをやらせていただきました。ユーザーさんの要望を調査したり、自分の興味に照らし合わせたりした結果、自分は通知機能をずっと触っていました。自分が開発した新機能は次の通り。

自主企画への参加通知

自主企画というのはユーザーが自分で小説イベントを開催できるカクヨムならではの機能です。自主企画はユーザー同士の貴重な交流の場の一つとなっているのですが、これまでは自主企画に参加作品が増えても主催者に知らせる機能がありませんでした。そのため、主催者は自分で参加作品が増えているか逐一確認しに行く必要があり、交流の妨げになっていると思いました。

この機能の開発は id:sken11 さんと共にペアプログラミングの形で作業を進めました。はじめに一日かけて通知機能周りのコードリーディングを行なって実装の理解を深めたのですが、これが技術的になかなか面白かったです。二週間ずっと通知機能を触っていたので、最終的には通知機能に関してならば、ノベルチームの中でもかなり詳しい人になれたかなと思います。その後はデザイナーの方々やディレクターの方を交えて会議を行い、通知の文言やアイコンのデザインを決めたり、その他の細かい仕様を決めたりしながら、実装を進めていきました。実装ではテストの重要性というのを改めて実感しました。本番環境にリリースする前に念入りにテストをするので、機能そのもののコードを書いていた時間より、テストのために割いた時間の方が長かったりします。

そんなこんなで8/27に無事最初のリリースを終えることができました。

ちなみにこの後、id:sken11 さんが各自主企画のトップページにおいて、参加作品を参加順に並べ替えることができる機能を開発してくれたので、この通知機能と合わせてより最強に仕上がっています。

週間ランキング上昇通知

週が明けてから残りの一週間ほどで、自分はランキングに関する通知を実装していました。Web小説投稿サイトでランキング関連の通知機能を備えているところって意外とないんですよね。これには様々な理由があると思いますが、その理由の一つにはおそらく、「誰にどこまでの範囲で通知を行うのか」を決めるのが難しいということがあると思います。たくさんのユーザーさんの心情の差異を考えて通知の仕様を決定する必要があり、このバランス感覚がとても難しいなとやってて実感しました。色々悩みましたが、最終的には「通知というものはネガティブなものではなく、めでたいことを共有したいから送るんだ」という思想のもと、「前日よりも順位が上がったら通知する」という仕様にしました。

技術的な面では結構初手で苦労しました。カクヨムの備える通知機能のアーキテクチャと、ランキング通知に必要とする要素との噛み合わせが少し悪かったんですよね。様々な実装候補を挙げてメンターの id:mfzy さんに相談し、ああでもないこうでもないと言いながら実装方法を決めていました。最終的にはメンターの id:k-murakami0609 さんの気付きによって、無事に期間内で実装できそうな方針に至ることができました。

こちらも9/2に無事リリースすることができました。実際に通知が初めて飛ぶのは翌日の朝だったので、テストをしっかり行なっているとはいえ、Twitterでユーザーさんの声を見るまではちゃんと動くかビクビクしていました。

成果発表

後半パートの最後には、各チームが開発した機能についてプレゼンを行う成果発表会がありました。はてなスタッフおよびインターン生による投票によって、最もホットなタスクを手掛けた*1チームを決めるのですが、そこでノベルチームが見事優勝することができました。優勝を目指して頑張っていましたが厳しい戦いになる*2と思っていたので、本当に優勝を掴み取ることができて嬉しいです。

最後に

今回のはてなインターンは、リモートでありながら実際のサービス開発までやるということでしたが、オンラインという制約の中で最大限のサポートをしていただけたなと感じています。実践パートではメンターのお二人を中心として、わからないことがあれば気軽に質問できる環境づくりがなされており、不自由なく開発を進めていくことができました。

そんなはてなインターンに参加させていただけて、本当に良かったなと満足しています。三週間があっという間でしたが、その中でかなり成長をすることができたんじゃないかと感じています。最後には成果発表で優勝することもできて言うことなしです。

最後になりますが、今回チーム配属でサポートをしていただいたメンターの id:k-murakami0609 さんと id:mfzy さんをはじめとするノベルチームの皆様、そしてはてなインターンを開催していただいたはてなスタッフの皆様に感謝いたします。おかげさまで最高の夏を過ごすことができました。

他のインターン生の方の参加記

kasheight.hatenablog.jp

toutounode.hatenablog.com

mds-boy.hatenablog.com

*1:はてな用語では略して「ほたて」

*2:実際、2位のマンガチームとはわずか2票差の僅差だった