この記事は、はてなデベロッパーアドベントカレンダーの19日目の記事です。
昨日は、id:t_kyt による あれから一年、あの TypeScript プロジェクトは今 - 多幸感 でした。
すばやく、かつ堅牢にアプリケーションをつくる
はてなの id:aereal です。アプリケーションエンジニアとして日々、サービスの開発に携わっています。
はてなではサービス開発合宿が年に一度ほどのペースで開催されています *1。今年もつい先日開催されました。
私たちのチームは技術的な挑戦を行う一方で、プロトタイプではなく初期実装となるプロダクトを作り上げることを目的として開発に取り組みました。
技術的挑戦について具体的にあげると、大きな視点ではサーバサイドを Go, クライアントサイドを TypeScript で書くことです。それも2日半で初期実装たりうるクオリティを最低限担保してのことです。
Go や TypeScript は既にプロダクションで採用されていますが、はてなにおけるチームすべてに浸透しているわけではありません。 実際、Go や TypeScript でプロダクションのコードを書いた経験がないメンバーが私たちのチームにもいました。
その他にもミドルウェアの選定やアーキテクチャの採用において、勢いを保ちつつもプロダクションへの投入を見据えてチーム全員で検討を行いました。
この記事では、今年の開発合宿で取り組んだ Web アプリケーションにおいて、ボタンの連打を効率よくかつ体験を損うことなく実現するために工夫した点について紹介します。
(なお、以下で紹介する Web アプリケーションは開発中のものであり、一般に公開されておりません。公開の予定も未定であることをあしからずご了承ください。)
ボタンを連打したくなる性と向き合う
私たちが取り組んだサービスでは、ページ上にボタンが存在します。クリックするとページに対して簡単なフィードバックのようなものがつけられます。 つけたフィードバックをあとから消すこともできます。 ちょうどはてなスターのようなものです。
ボタンがあれば連打したくなりませんか? *2
アプリケーションを作り提供する側としては、しばしばそのような業にも似た性は深刻な問題になりえます。
素朴に実装すると次のようなコードが考えられます:
function createFeedback(event) { return $.ajax({ // ... }); } const buttons = document.querySelectorAll('button[data-add]'); Array.from(buttons).forEach((button) => { button.addEventListener('click', createFeedback); });
これではあまりに素朴すぎ、いくつかの問題があります。
特に大きなものとして、クリックごとに HTTP リクエストが発生する、という問題は無視できません。
この課題に対して実際にどのような対策を行ったのか、サーバサイドとクライアントサイドについてそれぞれ紹介します。
サーバサイドにおける工夫
イベントソーシング
連打という文脈から少しずれますが、まずアーキテクチャにおける検討を紹介します。
アプリケーションのアーキテクチャを検討する際、「あるページにつけられたフィードバックのようなもの」をどのように保存するかをまず検討しました。
ユーザ (主体) * ページ (対象) * クリック (行動) というオーダーになるため、注意深く設計しないとレコード数が爆発したり、パフォーマンスを保つことがむずかしくなります。
そこで私たちは Event Sourcing と呼ばれるアーキテクチャをとることにしました。
詳細としては、ボタンをクリックすると HTTP リクエストが発生し、「フィードバックをつけた」というイベント *3 を表現するリソースをサーバへ送ります。 削除する際は、「このフィードバックを削除する」というイベントを表現するリソースをサーバへ送ります。
サーバでは、送られてきた「フィードバックをつけた」「フィードバックを消した」というイベントを表現するリソースを一旦、そのまま保存します。 サーバから表示すべきフィードバックの情報を返す際は、「つけた」「消した」というイベントを集めて計算した値を返します。
加えて、ある時点におけるスナップショットを保存することで計算するときにも効率化を図っています。この時、スナップショットをとった時点より古いイベントのレコードを削除することで、レコード数の増加を緩やかに保つことができます。
こうしたアーキテクチャにより効率よくデータを保存しつつ、データの書き込み時に担保すべき一貫性の範囲を小さく抑えられます。またパフォーマンスの低下も抑えることが期待できます。
リクエストに複数のリソースを含められるように
素朴な RESTful アーキテクチャでは、ひとつのリクエストでひとつのリソースを作るような実装がとられがちです。
例:
POST /users HTTP/1.1 Content-Type: application/json { "name": "aereal" }
これでは効率が悪いため、ひとつのリクエストに複数のリソースを含めて作成できるように実装しました:
POST /users HTTP/1.1 Content-Type: application/json { "users": [ { "name": "aereal" }, { "name": "noreal" } ] }
クライアントサイドにおける工夫
イベントのバッファリング
サーバサイドが複数のリソースを受け取れるようになっても、先にあげた click
イベントのハンドラで都度 HTTP リクエストを送るような素朴な実装のままではリクエストあたりに含められるリソースは1つのままです。
そこで click
イベントをバッファにためてまとめて送るように実装を変更しました:
function bulkCreateFeedback(events) { const resources = events.map(e => { /* some transform */ }); return $.ajax({ data: { resources: resources }, // ... }); } const buttons = document.querySelectorAll('button[data-add]'); let buffered = []; function onClick(event) { buffered.push(event); } Array.from(buttons).forEach((button) => { button.addEventListener('click', onClick); }); setTimeout(() => { if (buffered.length > 0) { bulkCreateFeedback(buffered); buffered = []; } }, 500);
これで HTTP リクエストは500ミリごとに高々1回まで抑えられます。
accumulate-call で簡潔に書く
バッファリングの詳細から離れて、アプリケーションコードを書く側から見ると「T[]
を受け取る関数」を「T
を受け取る関数」に変換している、といえます。
そこでこの素朴な関数の変換のみを行う accumulate-call という NPM モジュールを公開しました。
README から使い方を引用します:
import { accumulateUntil } from 'accumulate-call'; document.body.addEventListener('click', accumulateUntil((events) => { console.log(`Clicked ${events.length} times`); }, 1000));
ずいぶん簡潔になりました。見た目は素朴にイベントハンドラを与えているのと大差ありません。
また accumulate-call は TypeScript で書かれています。 型定義ファイルを NPM パッケージに含めているため、TypeScript 1.6 以降を使用している場合は DefinitelyTyped などの外部のリポジトリを参照することなく accumulate-call の型定義を tsc に伝えることができます。
参考: Typings for npm packages · Microsoft/TypeScript Wiki · GitHub
むすび
はてなでは、レイヤを横断して体験をよりよくする気概のあるエンジニアを募集しています。
(なお応募フォームは連打せずに1回だけ送信ボタンを押してください)
明日は id:hakobe932 です。
*1: サービス開発合宿 - Hatena Developer Blog
*2:私は連打したくなります。
*3: 特にことわりがない限り、「イベント」は DOM Event を、「リソース」は REST アーキテクチャスタイルにおける文脈のリソースを指すものとします。