はじめに
Bitbucket BrowserというBitbucket ServerのViewerをReactで作っていて、機能追加に伴い非同期処理部分がだんだん辛くなってきたので、勉強も兼ねてredux-sagaを使ってみたらどう改善されるのか試してみた。
なお、Bitbucketにはプラグイン機構があるのでプラグインとして実装するのが王道なのだが、Bitbucketが提供するREST APIを呼び出すだけで簡易的なViewerなら十分作れそうなので、HTML/JS/CSSのみで作っている。
使用ライブラリ
細かいライブラリも含めた使用バージョンは下記のとおり。なお、アプリケーション全体はTypeScriptで書いている。
"dependencies": {
"babel-polyfill": "^6.9.0",
"bulma": "0.0.26",
"font-awesome": "^4.6.1",
"lodash": "^4.12.0",
"react": "^15.1.0",
"react-dom": "^15.1.0",
"react-redux": "^4.4.5",
"react-select": "^1.0.0-beta13",
"react-sidebar": "^2.1.2",
"redux": "^3.5.2",
"redux-saga": "^0.10.4",
"whatwg-fetch": "^0.10.1"
}
非同期処理の概要
実装している非同期処理の内容について簡単に説明しておく。
Bitbucketが提供するREST APIをコールして下記情報を取得し、最終的にはブランチ単位で情報をテーブルに出力している(イメージ)。また、6番に関してはBitbucketではなくSonarQubeのREST APIをコールして取得している。
- Gitリポジトリ一覧
- Gitリポジトリ内のブランチ一覧
- Gitリポジトリ内のプルリクエスト一覧
- プルリクエストに紐づくSonar 4 Bitbucket Serverプラグインが提供する情報
- ブランチのビルドステータス
- ブランチの最新のメトリクス情報 (SonarQubeのAPIをコール)
上記の呼び出しは依存関係があり、
- 1 -> 2
- 2 -> 3
- 3 -> 4
- 2 -> 5
- 2 -> 6
- 2 -> 3
という順番で呼び出す必要がある。また大量のGitリポジトリ、ブランチを管理していると大量のアクセスが発生してしまうため、Gitリポジトリ一覧・ブランチ一覧までは初期表示時に全て取得するが、その他はページング制御で表示されたタイミングで初めてフェッチするように制御している。
(それでも利用しているプロジェクトでは相当数のリポジトリ・ブランチがあり、初回表示時に130リクエストくらい飛んでしまうが、待ち時間は許容できる範囲)
加えて、6番のSonarQubeのAPIコールはSonarQubeにまだログインしていない場合は認証完了を待ってからフェッチするようにも制御している(認証はログインモーダルを別途開いて行う)。
文字で書くと分かりにくいかもしれないので、この一連のREST APIコールのフローを図に書いてみた。
灰色の部分は画面の再描画で、取得した情報を表示するタイミングになる。取得できたものからどんどん描画するようにしている。雑に再描画指示を出してもReactが差分描画してくれるため、パフォーマンスが悪いということは今のところない。
初期バージョンの実装
Fluxは採用せず、シンプルにReactコンポーネント内でフェッチしてPromiseの解決後にsetState
をする、という方式でやっていた。
最初はコールするREST APIもたった2種類(Gitリポジトリ一覧とブランチ一覧の取得)だったので、これで十分だった。
しかしその後、プルリクエストの情報も出して欲しい、ビルドステータス※も出して欲しい、SonarQubeのメトリクス情報も出して欲しい、と要望が増えていき、Promiseの処理をどんどんネストさせることに、、、
さらに情報取得量が増えたため、初期表示時に全てフェッチするとかなり待たされるようにもなってしまった。そこでパフォーマンス改善のために表示するタイミングでフェッチするような遅延ローディングの仕掛けも加えていったが、かなり複雑な実装になってしまった。
※ BitbucketはJenkinsなどのビルドサーバと連携して該当ブランチのビルドステータスを取得できるようになっている。
redux-sagaで再実装
基本的には先程示した非同期処理のフロー図の処理をredux-sagaのtaskとして実装していけば良い。
非同期処理の開始点となる、pollFetchBranchInfosRequested
関数からどんな風に実装したか紹介する。なお、簡略化のため本質的でない物は一部処理を削っているところもあるので、実際の実装コードはソースを参照のこと。
この起点となるタスクではFETCH_BRANCH_INFOS_REQUESTED
アクションがどこかで発行されたら処理を開始するようにしている (アクションは初期表示時とリロードボタンクリック時に発行されるようにしている)。
Gitリポジトリの一覧を取得したら、その結果を別のタスクに渡して完了を待つだけ。なお、join
を使い子タスクの完了を待っているのは、全ブランチの取得を完了するまでにローディングアイコンを表示するため。api.fetchAllRepos
はPromiseを返す関数だが、Async/Awaitのように同期処理的に書くことができる。
function* pollFetchBranchInfosRequested(action: actions.FetchBranchInfosAction): Iterable<Effect> {
while (true) {
// アクション発行まで待つ
yield take(actions.FETCH_BRANCH_INFOS_REQUESTED);
// リポジトリ一覧の取得
const repos: API.Repo[] = yield call([api, api.fetchAllRepos]);
// リポジトリ一覧からブランチを取得するタスクをforkする
const task = yield fork(handleFetchBranchInfoAll, repos);
// ブランチ一覧取得の完了まで待つ (ローディングアイコン表示制御ため)
yield join(task);
// 完了のアクションを発行 (ローディングアイコンを非表示にする)
yield put(<actions.FetchBranchInfosAction>{
type: actions.FETCH_BRANCH_INFOS_SUCCEEDED
});
}
}
次はfork
された子タスクの内容。Gitリポジトリ毎にパラレルにフェッチさせるためにここではループで回してさらに子タスクをfork
するだけとしている。
function* handleFetchBranchInfoAll(repos: API.Repo[]): Iterable<Effect> {
for (let i = 0; i < repos.length; i++) {
const repo = repos[i];
yield fork(handleFetchBranchInfosPerRepo, api, repo);
}
}
その子タスクでは、ブランチ情報を取得し再描画を指示(APPEND_BRANCH_INFOS
アクションの発行)し、その後はこのブランチに紐づくプルリクエスト情報、ビルドステータス、SonarQubeメトリクス取得の処理タスクを起動している。
ポイントはここではspawn
を使っているところ。fork
にしてしまうと、全ての子タスクの処理が完了しないと本タスクが完了とならない。そうすると親の方でjoin
で待っていた箇所で止まったままとなり、ずっとローディングアイコンが出続けてしまう。プルリクエスト情報、ビルドステータス、SonarQubeメトリクスは画面表示で必要になったタイミングで遅延ローディングとさせたいため、親とは切り離されたタスクとして実行させる必要がある。そういうときはspawn
を使えば良い。
function* handleFetchBranchInfosPerRepo(api: API.API, repo: API.Repo): Iterable<Effect> {
// このリポジトリ内のブランチ一覧を取得
const branchInfos: API.BranchInfo[] = yield call([api, api.fetchBranchInfo], repo);
// 再描画指示
yield put(<actions.AppendBranchInfosAction>{
type: actions.APPEND_BRANCH_INFOS,
payload: {
branchInfos
}
});
// 遅延ローディング処理は分離されたタスクとして実行する
if (branchInfos.length > 0) {
// リポジトリ単位のタスクを起動
yield spawn(handleFetchPullRequestCount, branchInfos);
// ブランチ単位のタスクを起動
for (let i = 0; i < branchInfos.length; i++) {
yield spawn(handleBuildStatus, branchInfos[i]);
yield spawn(handleSonarQubeMetrics, branchInfos[i]);
}
}
}
前述の通り、プルリクエスト情報、ビルドステータス、SonarQubeメトリクス取得の処理は画面表示で必要になったタイミングで行うようにする。具体的には、ブランチ一覧のテーブル表示において、ページングで表示対象になったタイミングで初めてフェッチを行うようにする。
これを実現するために、SHOW_BRANCH_INFO_DETAILS_REQUESTED:(対象レーブル行データのユニークid)
アクションが発行されるまで待つようにした。別途テーブル表示を行うReactコンポーネントの方から、表示対象となった行データのユニークidをキーにしてこのアクションを発行するようにしている。
例としてビルドステータスの処理タスクのコードは下記のような感じ。遅延ローディングするかしないかは、このアクションのtake
による待ちだけで決まっておりかなりシンプルになった。
function* handleBuildStatus(branchInfo: API.BranchInfo): Iterable<Effect> {
const { id, latestCommitHash } = branchInfo;
// ページングで表示対象になるまでここで待つ
yield take(`${actions.SHOW_BRANCH_INFO_DETAILS_REQUESTED}:${id}`);
// 表示対象になったらREST APIをコールしてビルドステータスを取得
const buildStatus = yield call([api, api.fetchBuildStatus], latestCommitHash);
// 再描画指示
yield put(<actions.UpdateBranchInfoAction>{
type: actions.UPDATE_BRANCH_INFO,
payload: {
branchInfo: {
id,
buildStatus
}
}
});
}
なお、最初はSHOW_BRANCH_INFO_DETAILS_REQUESTED
という共通アクションでtakeして、そのpayloadにあるidで自分が対象かどうかチェックするという、下記のようなコード書いていた。しかしこれだと大量のブランチがある場合に性能に大きな影響が出てしまったので止めた(特にIEがひどく遅くなった)。ページングのたびに待っている全タスクが無駄に動いてしまうので、アクションの頻度、待ち状態のタスクが多い場合はできるだけ無駄に反応しないように気をつけて実装した方がよさそうである。
while (true) {
const action = yield take(`${actions.SHOW_BRANCH_INFO_DETAILS_REQUESTED}`);
if (action.payload.id === id) {
break;
}
}
もう一つ、spawn
で起動したSonarQubeメトリクス取得の処理タスクも紹介。こちらはビルドステータスより複雑になっている。SonarQubeのREST APIはBitbucketとは別のサーバのため、認証処理が別途必要になる可能性がある。既に認証済みであればそのままREST APIをコールして良いが、未認証の場合はユーザによるログイン処理完了を待つ必要がある。
といってもredux-sagaだと実装は簡単で、同じようにtake
で認証完了まで待つようにするだけで良い。なお、updateSonarQubeMetrics
関数は再利用できるように切り出しただけである。
function* handleSonarQubeMetrics(branchInfo: API.BranchInfo): Iterable<Effect> {
const { id } = branchInfo;
// ページングで表示対象になるまでここで待つ
yield take(`${actions.SHOW_BRANCH_INFO_DETAILS_REQUESTED}:${id}`);
// SonarQubeへの認証状態をStateから取得
const sonarQubeAuthenticated: boolean = yield select((state: RootState) => state.app.sonarQubeAuthenticated);
if (sonarQubeAuthenticated) {
// 認証済みであればSonarQubeのメトリクス情報取得タスクを起動
yield fork(updateSonarQubeMetrics, branchInfo);
} else {
// 未認証であれば、認証されるまで待つ
yield take(actions.SONARQUBE_AUTHENTICATED);
// 認証された後にSonarQubeのメトリクス情報取得タスクを起動
yield fork(updateSonarQubeMetrics, branchInfo);
}
}
function* updateSonarQubeMetrics(branchInfo: API.BranchInfo) {
const { id, repo, branch } = branchInfo;
// SonarQubeのREST APIをコールしてメトリクス情報を取得する
const sonarQubeMetrics: API.SonarQubeMetrics = yield call([api, api.fetchSonarQubeMetricsByKey], repo, branch);
// 再描画指示
yield put(<actions.UpdateBranchInfoAction>{
type: actions.UPDATE_BRANCH_INFO,
payload: {
branchInfo: {
id,
sonarQubeMetrics
}
}
});
}
所感
今回テストは一切書いていないので、redux-sagaのメリットを活かしきれていないものの※、redux-sagaによる非同期処理制御は今回のやりたいことにマッチしていると思った。全ての処理をフラットに書くことができるので上から順に読んでいくだけで処理を追うことができるようになり、コードの見通しがかなり良くなったと思う。
また、今後の追加要望でBitbucketからの取得情報が増えたとしても、新たにタスクを定義してfork
or spawn
するだけで容易に追加ができるようにもなったのも良い。
※ redux-sagaだと非同期処理をモックなしでシンプルにテストができる。詳しくは@kuy 氏の[redux-sagaで非同期処理と戦う] (http://qiita.com/kuy/items/716affc808ebb3e1e8ac#%E3%83%86%E3%82%B9%E3%83%88%E3%82%92%E6%9B%B8%E3%81%84%E3%81%A6%E3%81%BF%E3%82%8B)を参照のこと。