今年、私たちの開発チームでは、既存のInflearnサービスのコードを新しいスタックへ刷新するプロジェクトを進めました。
複数のサービスのうち、講義室ページの改善が最初の目標で、最近その作業が完了しました。
本記事では、プロジェクトを進める中で得た経験を共有したいと思います。
刷新の必要性
既存のInflearnプロジェクトは、次のようなスタックで構成されています。
- Node.js
- PostgreSQL
- Express
- FxJS
- MQL
- FxDOM
FxJS のような関数型ライブラリと、FxSQL と呼ばれるクエリビルダーよりも前にリリースされた MQL2 を使っています。
本記事では、同じFxベースのライブラリであることを示すため、MQLの代わりにFxSQLと表記します。
アプリケーションロジックのほとんどは、FxJS の go 関数を活用して、小さな単位の関数を複数合成する形で書かれています。
下のコードを見ると、SQLを実行した結果から始まり、groupBy、map のような関数を使って加工しています。
Indong Yooさんの関数型プログラミング講座を受講された方には、見慣れた形だと思います。
変数名とDBのフィールド名は任意に変更しています。
このような環境で、大きな変化もなく長い期間使ってきており、その分プロジェクトの規模も大きくなりました。
そのためさまざまな問題が発生し、特に今年経験した大きな障害をきっかけに、改善の必要性を感じるようになりました。
本記事では、経験したさまざまな問題のうち一部を紹介したいと思います。
動的型付け言語
JavaScriptは代表的な動的型付け言語で、変数宣言時に型を指定する必要がなく、実行時に柔軟に型が決まります。
特に、私たちが使っている FxJS は、こうした柔軟性をベースに作られたライブラリです。
動的型付けのため、ユーザーのリクエストデータやDBのデータを加工する際に、内部にあるフィールド情報についてIDEのサポートを受けにくいです。
そのため、開発者が自分でフィールドを追跡しなければならず、常に次の点に注意する必要がありました。
- フィールド名を入力する際に、タイプミスをしないよう気をつける必要があります。
- nullableなフィールドにアクセスする際に、事前チェックのロジックを漏らさないよう多くの労力が必要です。
下の画像は、実際にnullableのチェック漏れが原因で発生したエラーです。
サーバーの起動速度
Inflearnのプロジェクトはチーム内で エントマン と呼ばれており、FEとBEのコードが1つのリポジトリにあります。
ReactのようなSPAベースのページも一部ありますが、ほとんどは PHP や JSP のようなサーバーサイドレンダリングを使っています。
つまり、ページを移動するたびに、ブラウザーに表示されるすべての領域をサーバーでHTMLとして生成し、レスポンスとして送信します。
ただし、ページ移動を伴わないユーザーイベントに対するリクエストはAjaxで行い、必要な部分だけを再レンダリングします。
そのために、jQuery のようなDOMセレクターとイベントハンドリングの方式を使っています。
こうした理由から、開発者がローカルでサーバーを起動するには、クライアント向けのビルド工程が必要です。
プロジェクトの初期は特に問題ありませんでしたが、時間が経つにつれてコード量が増え、その結果、初回のサーバー起動速度が遅くなりました。
上の画像は初回のサーバー起動速度を記録した画面で、PCのスペックによって異なりますが、1分10秒〜1分20秒ほどかかります。
サーバー起動後、コードの変更による再起動にはおよそ10〜20秒かかります。
このように、修正内容を確認するためにかかる時間がだんだん増え、その結果、生産性も低下してしまいました。
新入社員のコードへの適応
前章で触れたとおり、現在Inflearnを構成するコードのほとんどは、FxJSとFxSQLのライブラリを使って書かれています。
JavaScriptで関数型プログラミングができるというメリットがある一方、ほとんどの入社者がこれまで書いてきたコードスタイルとかなり異なるため、慣れるのが難しい部分があります。
関数型プログラミングに慣れている開発者であれば、関数名(map、reduce、filter、tapなど)から動作を推測できますが、慣れていない新入社員はドキュメントを参照する必要があります。
しかしFxJSの場合、ドキュメントの例が不足しているため、既存コードで知らない関数に出会うと、コードを読んで動作を把握するのに時間を費やすことになります。
また、このような書き方ではコードが手続き的に書かれるため、コードの凝集度が下がり、全体像の把握が難しくなります。
次に苦労するのがFxSQLです。
FxSQLは、SQLインジェクション攻撃への対策が施されており、1:1、1:N、M:N形式のクエリを簡単に表現できます。また、取得したクエリ結果の後処理(hook)や、モジュールに分離することでクエリの再利用性を高めることもできます。
しかし、これらの機能は多くの開発者に馴染みのあるJOINではなくSELECT IN方式を使うため、開発経験の少ないメンバーにとっては理解しづらいことがあります。
また、hookは主に複数のテーブルから取得したデータを加工するために使われますが、取得したデータにどんな型のフィールドがあるのかを追跡しにくく、hookがネストしている場合はデータの変更の流れを把握しにくくなることがあります。
export const something = a_id => go(
Promise.all([ActiveDC(a_id), MyGroup(a_id)]),
([activeDC, group]) => RDB.AS`
c_list ${{
table: $TB.CT,
query: SQL`${WanD(
{user_id: a_id},
SQL`"c_id" in ( select "id" from "c" ${WanD(is_enrollable)} )`,
SQL`"c_id" not in ( ${myValidSomthing({a_id: a_id})} )`,
)} ORDER BY "updated_at" desc, "id" desc`,
hook: pipe(
ct => go(
ct,
uniqueBy(sel('c_id')),
L.map(sel('c_id')),
grouping(20),
C.map(someFn(a_id, activeDC, group)),
flat,
cs => go(carts, map(_extend(({c_id}) => ({c: go(cs, findWhere({id: c_id}))}))))),
filter(sel('_.c._.p_info.is_order'))),
}}`);
上の例は変数名と関数名を任意に変更したコードです。ロジックを理解するよりも、コードの構造だけを把握していただければと思います。
こうした理由から、新入社員がチームに貢献できるようになるまでに時間がかかりがちでした。
講義室
講義室ページはほかの部分と関連する項目が少ないため、最初の刷新対象に選びました。
今回の作業では、データベースのスキーマを変更せず、コードベースだけを変更することを目標にしました。
最終目標である障害の伝播が隔離された分散環境のためにはスキーマの変更が必須でしたが、これはデータマイグレーションのような厄介な追加作業を伴うため、第1段階としてコードだけを改善することにしました。
既存APIの分析
現在、講義室のAPIはたった1つだけで、コミュニティ、ノート、カリキュラムのデータをすべて取得してレスポンスとして返しています。
特に、テーブルのすべてのフィールドを取得するコードが多く、レスポンスの中にはまったく使われていないフィールドがかなりありました。
特に、カリキュラムの数が多い講座では、レスポンスのサイズが30kB近くになることもありました。
レンダリング用のHTML bodyのサイズではなく、APIリクエストのJSONレスポンスのサイズとしては、非常に大きいことが分かります。
上のデータを取得するのにおよそ20個のテーブルを使っていますが、主に使うデータは2〜3個のテーブルにあります。
実は、上のようなデータを取得するコードの行数自体は少ないほうです。
各テーブルの取得ロジックを1つの関数にして複数の場所で再利用しており、FxSQL と FxJS の関数でデータをマージして加工しています。
再利用性は大きな利便性をもたらしますが、注意しないと想像以上に大量のデータを取得する結果につながりかねません。
既存サーバーとの連携
講義室ページは、ログインユーザーの受講権限に応じて異なる動作をする必要があるため、セッション情報が必要です。
新しいサーバーにセッションを直接処理させることもできますが、そうすると新しいサーバーは講義室ドメイン以外に認証のドメインに依存することになります。
そのため、もしセッションの処理方式を変えたり、専用の認証サーバーを作ろうとしたりすると、講義室サーバーも一緒に変更しなければならないという問題が発生します。
これを解決するために、既存サーバーは認証だけを担当し、APIリクエストの処理は新しいサーバーに任せることにしました。
つまり、すべてのAPIリクエストはまず既存サーバーに向かい、既存サーバーがAPI Gatewayの役割を果たします。
詳しく説明するために、新しい講義室にアクセスするリクエストが処理される流れを、図で見てみましょう。
まず、新しいフロントエンドのスタックではReactを使っており、デプロイ時にビルド成果物をCDNにアップロードします。
その後、講義室ページへの初回アクセス時に、既存サーバーはビルド成果物のうち index.html だけをCDNから取得し、レスポンスとして返します。
このファイルにはReact用の追加のJS・CSSファイルのCDNアドレスが含まれており、ブラウザーはそれらを取得して実行します。
その後、Reactが初期化される際に、画面の描画に必要なデータを既存サーバーにリクエストします。
既存サーバーはリクエストを受け取り、セッション情報だけを追加して新しいサーバーにリクエストを送ります。
新しいサーバーはそれを使って、既存サーバーが行っていたDB取得ロジックを実行してレスポンスを返し、そのレスポンスは既存サーバーを経由してユーザーに送信されます。
NestJS
NestJS はNode.js界隈のサーバーフレームワークで、次のようなメリットがあります。
- 依存性の注入
- テストコード環境の構築
- モジュール単位でのコード記述
- 活発なコミュニティ
- TypeScriptベース
- CLIの提供
Nest.jsは、Node.jsが解決できていなかった問題を効果的に解決したフレームワークで、オブジェクト指向的なコードを書けるようサポートします。
特に、依存性の注入と、それを活用したテストコードを簡単に書ける点が大きな魅力だと思います。
こうした機能をもとに、既存のコードをレイヤードアーキテクチャベースに書き換える作業を進めました。
MikroORM
MikroORM はTypeScriptのORMライブラリで、Data Mapper、Unit of Work、Identity Mapパターンを使っています。
以前のRallitプロジェクトで使ったTypeORMで次のような問題に直面したため、今回導入を試みました。
- Transformer関連の問題
私たちは日付を扱うデータに js-joda の LocalDateTime を使っており、そのためにtransformerを使っています。
しかし、クエリビルダーのwhere条件に LocalDateTime を渡すと、transformerが正しく動作しない現象が起きます。
また、テストを高速化するためにSQLiteを使おうとしても、transformerが動作しないため使えないという問題があります。
- ライブラリのメンテナンス
TypeORMはいまだにメジャーバージョンが出ておらず、メンテナーがこれ以上時間を割けないため、開発が遅れている状況です。
新機能の追加もなく、上記のtransformer関連の問題も長い間解決されていないため、ライブラリの将来は不透明です。
一方MikroORMは継続的にメンテナンスされており、上記の問題も発生しないため、今回導入を決めました。
ふりかえり
ここでは、プロジェクトについての経験と個人的な考えを共有したいと思います。
テストコード
今回のプロジェクトでは、常にテストコードを書くことを目標に開発しました。
既存のロジックを把握し、その内容をテストケースとして追加して同じ結果が得られるかを確認していく中で、安心感を得ました。
プロダクションコードに比べてテストコードを書く時間と量のほうが多くなりましたが、今後発生するかもしれないバグの解決にかかる時間を考えれば、長期的にはより効率的な開発方法だと思います。
私が考えるレガシーコードの定義は、単に書かれてから時間が経ったコードではなく、テストのないコードです。
どれだけ昔に書かれたコードでも、テストコードがあれば、今後発生する新しい要件を反映するために既存のコードを修正する際の不安を減らせます。
また、テストケースの内容が1つのドキュメントとして機能するため、新入社員がコードベースに慣れる助けにもなると考えています。
技術選定
TypeORMを使っていて感じた不満点が解消されたMikroORMを、プロジェクトの序盤は満足して使っていました。
しかし時間が経つにつれて、いくつかの不満点が見えてきました。
- エンティティファイルで、関連テーブルのフィールドはライブラリが提供するwrapperで包む必要がある
- TypeORMに比べて貧弱なQuery Builderの機能
- Repositoryを使うコードを見ても、実際に実行されるSQLを予想しにくい場合がある
各項目の詳しい説明は、最後の章を参照してください。
実は、ライブラリの導入を検討する段階で上記の問題の一部には気づけていたのですが、type-safeな開発を好む個人的な志向から、こうした問題を見過ごしてしまったのだと思います。
これを改善するため、開発を進めながら、ライブラリに依存する領域をできるだけ減らすよう努めました。
例えば、レイヤードアーキテクチャでよく使われる概念であるrepositoryにだけライブラリ依存のコードを置き、ライブラリを入れ替える際に上位レイヤーのコードが影響を受けないようにしました。
ただ、エンティティファイルとテストコードにはライブラリ依存のコードがあり、多くの修正が必要になる点は心残りです。
それでも今回のプロジェクトを通じて、技術選定では個人の好みよりも、現在のチームが目指す方向を重視して考えるべきだということを学べました。
今はMikroORMに不満な点もありますが、TypeORMと違って継続的にメンテナンスされているので、今後改善される可能性はあると思います。
Node.js環境で、JPAが提供する永続性コンテキストのような機能を求めているなら、良い候補になり得ます。
おわりに
今回の刷新作業によって、結果的にコードの行数は以前より増えましたが、保守性の面ではより良くなったと思います。
私は関数型パラダイムがとても好きで、コードをできるだけ簡潔に書くことを好みますが、大きくなっていくチームと持続可能なコードのためには、多くの開発者にとって馴染みのある構造を保つ努力が必要だと考えています。
今回のプロジェクトは、新機能の追加ではなく、既存の機能を再実装するリファクタリング的な性格の作業でした。
そのため、現時点ではビジネス上の価値はありませんが、今後はより速く安定した開発環境によって、これまで以上に多くの価値を生み出せることを願っています。
MikroORMの問題点
Query Builder
TypeORMと違い、MikroORMはQuery Builderの機能に制限があります。
説明のために、まずよくある投稿(post)とコメント(comment)のエンティティがあると仮定しましょう。
もし、IDが123のコメントのいいね数(comment.like)と投稿のタイトル(post.title)を取得するクエリを書くとしたら、次のようなコードになります。
const result = await commentRepository
.createQueryBuilder('comment')
.select(['comment.id', 'comment.like', 'post', 'post.title'])
.join('comment.post', 'post')
.where({id: 123})
.getSingleResult();
selectメソッドの引数に 'post' がありますが、これはpost全体ではなく、post.idフィールドを意味します。
コードを初めて見る人が想像する意味とは異なるため、注意が必要です。
実際に実行されるクエリを見ると、期待どおりに動作しています。
SELECT `comment`.`id`, `comment`.`like`, `comment`.`post_id`, `post`.`name`
FROM `comment` AS `comment`
INNER JOIN `post` AS `post` ON `comment`.`post_id` = `post`.`id`
WHERE `comment`.`id` = '123'
しかし実際の結果を確認すると、投稿のタイトルはなく、IDだけが存在していることが分かります。
これを解決するにはクエリビルダーのjoinの代わりにjoinAndSelectを使う必要がありますが、そうすると不要な投稿のフィールドまですべて取得してしまうという問題が発生します。
const result = await commentRepository
.createQueryBuilder('comment')
.select(['comment.id', 'comment.like']) // postのすべてのフィールドを取得する
.joinAndSelect('comment.post', 'post') // joinAndSelectに変更
.where({id: 123})
.getSingleResult();
ライブラリ内部で使われている Knex を直接使って結果をJSON形式で取得し、クラスのインスタンスにマッピングすれば解決はできます。
しかしこの方法では、type-safeではないJSONを扱うコードを書く必要があり、これはミスが起きる確率が高くなることを意味します。
エンティティの宣言
コメントのエンティティに投稿のエンティティを関連付ける場合を考えてみましょう。
TypeORMであれば、次のようなコードを書くことになります。
@Entity()
export class Comment {
@ManyToOne(type => Post, post => post.comments)
@JoinColumn()
post: Post;
}
このように書いたうえで、新しいコメントを追加するためにcommentのインスタンスを作る場合、通常は次のようにpostのIDだけを使ってpostのインスタンスを作り、postフィールドに設定します。
static create(postId:number, body:string): Post{
const comment = new Comment();
comment.post = new Post()
comment.post.id = postId;
comment.body = body;
return comment;
}
しかし、このように作成したインスタンスをsaveするとエラーが発生します。
その理由は、new Post() で直接作ったインスタンスは、MikroORMの永続性キャッシュで管理されていない項目のため無視されるからです。
これを解決するには、ライブラリが提供するwrapperを使う必要があります。
@Entity()
export class Comment {
@ManyToOne(type => Post, post => post.comments)
post: IdentifiedReference<Post>; // IdentifiedReferenceで包みます
static create(postId: number, body: string): Post {
const comment = new Comment();
// ライブラリが提供するcreateFromPKメソッドでインスタンスを生成します
comment.post = Reference.createFromPK(Comment, postId)
comment.post.id = postId;
comment.body = body;
return comment;
}
}
このような方法は、インスタンスの生成がライブラリに依存することになり、将来ほかのORMに変更する際に、大量の生成コードを修正しなければならなくなることを意味します。







