iOS版CODEアプリのアーキテクチャと使用ライブラリ
リサーチ・アンド・イノベーションの小川です。iOSエンジニアをやっています。弊社ではiOSエンジニアを募集しています。この記事でもAndroidアプリと同様に応募者が弊社で働くイメージを持ちやすくするために、iOS版CODEアプリのアーキテクチャと使用ライブラリを広く簡潔に紹介します。
アーキテクチャ
アーキテクチャは4層構造のレイヤードアーキテクチャになっています。 MVVM+クリーンの改造版的なイメージです。チームで使い勝手を良くするために他のアーキテクチャを参考に制定してみました。
非同期処理
GCD, RxSwiftを使用しています。 今後、Combineを使用予定です。
プレゼンテーション層
- View, ViewController, ViewModelが置いてある
- ユーザーからの入力を受けて表示する
アプリケーション層
ドメイン層
インフラ層
- データ取得ロジックが記載されている
- Repositoryの実装クラス
- データの永続化
その他モジュール
Utility
- 全体で使用する便利classが置いてある
- だいたい何かのExtension
Config
- アプリケーション全体で使いたい定数などが纏められている
実装について
Repository
データの出し入れを代表するクラスです。単純なラッパーになっているケースが多いですが、REST APIからデータ取得する場合はcacheの管理や取得データをストリームに変換して返す処理を行なっています。
UseCase
ViewModelからRepositoryを使う場合はUseCaseクラスを作成しています。
ViewModel
ユーザのInputに対しOutputを返す存在として実装しています。インターフェースとして以下を採用しています。
protocol ViewModelProtocol { associatedtype Input associatedtype Output func transform(input: Input) -> Output }
ユーザの操作は全てInputに、UIの変化は全てOutputに集約させるように実装しています。 Input, OutputはRxのストリームで表現していて、リアクティブプログラミングでイベントの実装をしています。 こちらについては一つの方法に拘りがあるわけではなく、チーム内で話してより良い解決策を採用しています。
View
各ViewControllerに対して.xibファイルを作成しています。
ViewController
1つのViewControllerに1つのViewModelを結びつけ、ユーザの操作をViewModelへのInputとし、画面変更をViewModelからのOutputとして実装しています。
テスト
ユニットテスト
XCTestを使用しています。RxSwiftに関する部分についてはRxTestを使用しています。 テストカバレッジについてはまだ不十分なので、プロジェクトの課題です。
UIテスト
Appiumにてスクリプトを作成しています。本アプリではカメラの操作がメインアクションなのでまだまだ実験導入の段階となっています。
ワークフロー
CI
Githubでのプルリクエスト作成時・それ以降のPUSH毎にローカル単体テストの実行を行なっています。
CD
TestFlightを使った社内テスト版の配布やAppStoreへのリリースもワークフロー化されています。
分析
Firebase Analyticsで画面遷移やアプリに埋め込んだイベントを送っています。さらにBigQuery Exportを設定して、BigQueryにGoogle DataStudioやJupyterからアクセスすることで、柔軟にデータ分析を行っています。データ分析エンジニアが居るのでグロースのためのデータ分析は、ほぼその方が担当しています。Androidチームでは重要機能が適切に動作しているかの確認や、ユーザから報告のあった不具合の調査のための分析を主に行っています。
まとめ
現状の課題
CODEは2013年に開発が開始したプロジェクトで、当初に開発された画面はObjective-Cで書かれていてそれが残っています(アーキテクチャとしてはFluxを採用して、グローバルな単一stateをKVOで監視することで実現しています)。レガシーコードについてはViewController単位で最新の実装方法に切り替えて作り直す方針にしています。
今後の展望
今後は今まで少人数の開発体制だったものを拡大していく予定なので、大人数でもうまくワークする開発体制の整備が必要と考えています。 また、開発効率や安全性の向上のためにも積極的に新しい技術を導入したいと考えております。一緒にチャレンジいただける方のご応募をお待ちしております。 (2022年2月の時点での具体的な検討事項は以下となります)
- SwiftUI/Combineの導入を検討中
- GraphQLの導入を検討中
- E2Eテストの自動化を検討中
- アーキテクチャの改善
Android版CODEアプリのアーキテクチャと使用ライブラリ
リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。弊社には3人目のAndroidエンジニアを採用する予定があり現在準備中です。この記事では応募者が弊社で働くイメージを持ちやすくするために、Android版CODEアプリのアーキテクチャと使用ライブラリを広く簡潔に紹介します。
アーキテクチャ
アーキテクチャは5層構造のレイヤードアーキテクチャになっています。Android公式のアプリの推奨アーキテクチャのViewModelとRepositoryの間にUseCaseを加えた構造を採用しています。UseCaseを加えた理由はViewModelの単体テストの肥大化を防ぐためです。
非同期処理
Kotlin CoroutinesをFlowを含めて使用してます。
RemoteDataStore
サーバサイドはRuby on Railsで作成されたREST APIになります。そのクライアントをRetrofitで作成しています。
LocalDataStore
Roomを使用して複数Activityにまたがるデータを永続化して、プロセスキルからの復帰に対応しています。フローを使用したリアクティブ クエリを使い、他の画面で行われた更新も抜けなくUIに反映するようにしています。(所謂いいね問題への対応)
Repository
上記DataStoreクラスを使ってデータの出し入れを代表するクラスです。単純なラッパーになっているケースが多いですが、fetch○○というメソッドでは、REST APIからデータ取得したあとRoomへの保存します。
UseCase
ViewModelの1メソッドに対してRepositoryを使う場合は1つのUseCaseクラスを作成しています。
ViewModel
DroidKaigi/conference-app-2021のUnidirectionalViewModelを参考にして、stateとeffectをひとつずつ持っています。Viewの状態はstateが持ち、ダイアログ、Toast、画面遷移のような単発イベントがeffectになります。
ViewModelはこちらのインターフェースを実装するようにしています。ISingleLiveEventインターフェースはAndroid Architecture BlueprintsのSingleLiveEventのインターフェースです。
interface CodeBaseViewModel<State, Effect> { val state: LiveData<State> val effect: ISingleLiveEvent<Effect> }
View
レイアウトはXMLで作っています。Jetpack Composeはまだ導入していません。 そして多くの画面でgroupie(RecyclerView)を使用しています。クエスト一覧画面のような、いかにもリストな画面だけでなく、詳細画面を含めて幅広く使用しています。こちらはクックパッドさんの事例と同じ内容になります。
クックパッドマートAndroidアプリの画面実装を最高にした話【連載:クックパッドマート開発の裏側 vol.4】
DI
現在はKoinを使用しています。DI導入時にDagger Hiltは無かったのですが、Koinは設定ミスの検出がビルド時ではなく実行時であるというデメリットを考えると、工数をかけてDagger Hiltに差し替える選択もありだと思います。
マルチモジュール
ビルドの高速化やレイヤードアーキテクチャへの強制力のために、レイヤー別と機能別のマルチモジュールを採用しています。レイヤー別モジュールにはremoteDataStore, localDataStore, repositoryがあります。UseCase以上は機能別モジュールに格納しています。マルチモジュールを導入する前のクラスはlegacyモジュールに格納しています。
規模
関連して規模感も説明します
項目 | 数 |
---|---|
Activity数 | 128 |
アプリコード行数 | 160,447 |
テストコード行数 | 88,472 |
テスト
弊チームでは自動テストを重視しています。弊社はアジャイルを採用していて、素早く開発して素早く社内テストや一部ユーザを対象としたリリースをしてフィードバック頂いて、素早くアプリを変化させることを良しとしています。しかしデグレの懸念があると、それが障壁になります。
RemoteDataStore
APIクライアントのテストはOkttp3のMockWebServerを使い、インストゥルメント化単体テストを記述しています。
LocalDataStore
Roomのテストもインストゥルメント化単体テストで保存とクエリを確認しています。
Repository、UseCase、ViewModel
Repository、UseCase、ViewModelについてはMockKを使って単体テストを記述しています。coEvery
メソッド等でひとつ下のレイヤーの返却値を定義したのち、テスト対象メソッドを呼び出し、coVerifySequence
メソッド等でひとつ下のレイヤーに対して呼び出された内容を検証しています。
ViewModelについては、LiveDataの変化も onChanged
メソッド呼び出しを確認する形で検証しています。
テストコードの例
ここでは家計簿の収入を登録および編集する画面を例にして、そのViewModelの実装とテストコードを紹介します。
※ 近日中にリニューアル予定の画面です。
まずテスト対象のViewModelです。収入を登録する部分以外は省略しています。
data class IncomeEditState( val progress: Progress = Progress.NOTHING, val price: Int = 0, val selectedIncomeCategoryId: Long = 0, val date: LocalDate = LocalDate.of(1900, 1, 1), val memo: String = "" ) { enum class Progress { LOADING, PROCESSING, NOTHING } } sealed class IncomeEditEffect { object Close : IncomeEditEffect() } class IncomeEditViewModel( private val incomeCreateOrUpdateUseCase: IncomeCreateOrUpdateUseCase ) : ViewModel(), CodeBaseViewModel<IncomeEditState, IncomeEditEffect> { private val _state = MutableLiveData(IncomeEditState()) override val state: LiveData<IncomeEditState> get() = _state private val _effect = SingleLiveEvent<IncomeEditEffect>() override val effect: ISingleLiveEvent<IncomeEditEffect> get() = _effect val error = ApiErrorViewModelHelper() /** * 登録/更新ボタンが押された時に呼ばれる * * @param id サーバ側ID。0Lの時は登録。 * @param price 金額 * @param incomeCategoryId カテゴリID * @param date 日付 * @param memo メモ */ fun submit( id: Long, price: Int, incomeCategoryId: Long, date: LocalDate, memo: String ) = viewModelScope.launch { try { // 処理中としてプログレスを表示する _state.value = state.value?.copy(progress = IncomeEditState.Progress.PROCESSING) // 収入を登録するUseCaseを呼び出す // UseCase内部では登録API呼び出しと収入一覧の再フェッチが行われている incomeCreateOrUpdateUseCase.createOrUpdate(id, price, incomeCategoryId, date, memo) // 画面を閉じる _effect.value = IncomeEditEffect.Close } catch (e: Throwable) { // エラー処理 error.onError(e) } finally { // プログレスを元に戻す _state.value = state.value?.copy(progress = IncomeEditState.Progress.NOTHING) } } }
LiveDataの変化をMockKで確認するための拡張関数です。
fun <T> LiveData<T>.mockObserver(name: String = ""): Observer<T> { val mockObserver = mockk<Observer<T>>(relaxed = true, name = name) observeForever(mockObserver) return mockObserver } fun <T> ISingleLiveEvent<T>.mockObserver(name: String = ""): Observer<T> { val mockObserver = mockk<Observer<T>>(relaxed = true, name = name) observeForever(mockObserver) return mockObserver }
ViewModelのテストはこのようになります。
@ExperimentalCoroutinesApi @Test fun submit() = testDispatcher.runBlockingTest { val stateMockObserver = viewModel.state.mockObserver() val effectMockObserver = viewModel.effect.mockObserver() viewModel.submit( 0L, 1500, 2L, LocalDate.of(2021, 8, 10), "メモ" ) // LiveDataの変化とUseCase呼び出しを確認する coVerifySequence { stateMockObserver.onChanged(IncomeEditState()) stateMockObserver.onChanged( IncomeEditState( progress = IncomeEditState.Progress.PROCESSING ) ) incomeCreateOrUpdateUseCase.createOrUpdate( 0L, 1500, 2L, LocalDate.of(2021, 8, 10), "メモ" ) effectMockObserver.onChanged(IncomeEditEffect.Close) stateMockObserver.onChanged( IncomeEditState( progress = IncomeEditState.Progress.NOTHING ) ) } }
※ 説明のために簡略化しているので、実際のプロダクトのコードとは異なります。
View層
EspressoやAppium等を使ったUIの自動テストはほぼ無く、現在の課題です。
ワークフロー
主にAWS CodeBuildとGitHub Actionsを使用しています。
CI
プルリクへのPUSH毎に以下の内容を実行しています。
- ローカル単体テスト
- インストゥルメント化単体テスト(Firebase Test Lab)
- Lintの実行とプルリクへの投稿(Danger)
- フォーマットチェック(Spotlessで構築、検証中)
- カバレッジレポート作成(Jacoco + Codecovで構築、検証中)
処理時間が25分かかるので、キャッシュの設定やRobolectricの使用などで短くすることを検討中です。
CD
DeployGateを使った社内テスト版の配布やGoogle Playへのリリースもワークフロー化されています。
分析
Firebase Analyticsで画面遷移やアプリに埋め込んだイベントを送っています。さらにBigQuery Exportを設定して、BigQueryにGoogle DataStudioやJupyterからアクセスすることで、柔軟にデータ分析を行っています。データ分析エンジニアが居るのでグロースのためのデータ分析は、ほぼその方が担当しています。Androidチームでは重要機能が適切に動作しているかの確認や、ユーザから報告のあった不具合の調査のための分析を主に行っています。
古いコード
前述したアーキテクチャは開発の新しい画面で採用しているもので、以前開発した画面では違う技術やライブラリが使われています。
Fluxアーキテクチャ
少し前に開発した画面ではDroidKaigi/conference-app-2019を参考にしたFluxアーキテクチャを採用していて、View層とRepository層の間はActionCreatorとStoreになっています。こちらは単体テストが記述されているので、改修するときもそのままFluxアーキテクチャを使い続けようと考えています。
レガシーコード
さらに以前に開発された画面はJavaで書かれていて、API呼び出しもAsyncTask + HttpUrlRequestになっています。単体テストは無いです。このようなコードに対する考え方は、動いているコードは弄らず機能改修するときはActivity単位で全部新しく作っています。弊社PdMは技術的負債の解消について理解がある方で助かっています。
まとめ
安全かつ高速にアプリを進化させるために有用となるガイドライン策定や技術の導入は何でもしようと思います。まだ不完全なところがあるので、一緒に頑張って頂ける方を募集する予定です。
ドラッグ可能かつクリック可能なViewを作る
リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。昔から独自のUI部品やユニークなアニメーションを作ることにはこだわっていましたが、今回作ったドラッグ可能かつクリック可能なViewもなかなかの自信作なので紹介しようと思います。
FINE演出
CODEの買い物登録では、まずレシートを撮影して頂きますが、撮影されたレシート画像はOCR(光学的文字認識)処理され、日付、合計金額、店舗電話番号が読み取られます。CODEではサードパーティー製のレシートOCRライブラリを使用してOCR処理を行っています。クラウドの力は借りずにリソースの限られたスマホ端末内で完結して処理が行われますが、認識精度と処理速度の両面で高い完成度を持ったライブラリだと思います。とは言っても、レシートが遠すぎたり手ぶれしていたりピンボケしていたりすると読めません。
適切な撮影に誘導する仕組みとして、日付、合計金額、店舗電話番号のすべてが読み取れるとブタさんのキャラクターが褒めてくれる演出を作成しました。社内では「FINE演出」と呼ばれています。
クリックできる
FINE演出は左上のXボタンクリックで閉じることができます。閉じると小さい閉じたFINE演出になります。撮影状態やレシートの記載を確認できるようにするためです。そして閉じたFINE演出をクリックすると再び大きなFINE演出にできます。
ドラッグできる
しかし閉じたFINE演出でもレシートの表示に被ります。そこでドラッグで移動可能にしました。さらにそれによって読み取り結果が合っているかを該当箇所の隣にドラッグして確認することもできます。
ドラッグ可能かつクリック可能なViewの作り方
それではドラッグ可能かつクリック可能なViewの作り方を紹介します。それを実現できるライブラリが無いか探してみたのですが、見つけることができませんでした。なのでAndroid SDKの機能を組み合わせて実現しました。
まずレイアウトファイルはタッチイベントをキャッチする担当Viewと閉じたFINE演出に分けます。
<!-- 略 --> <FrameLayout android:id="@+id/fine_view_close_parent" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"> <!-- 閉じたFINE演出(ドラッグ可能かつクリック可能) --> <include android:id="@+id/fine_view_close" layout="@layout/view_receipt_confirm_fine_close" /> <!-- タッチイベントキャッチ担当(見えない) --> <View android:id="@+id/fine_close_touch_catcher" android:layout_width="match_parent" android:layout_height="match_parent" android:visibility="invisible" /> </FrameLayout> <!-- 略 -->
下記のコードが閉じたFINE演出find_view_closeをクリック可能かつドラッグ可能にするコードです。ドラッグおよびクリックのイベントはすべてタッチイベントキャッチ担当fine_close_touch_catcherが担当します。取得したタッチイベントを処理した結果をfind_view_closeに反映させています。移動ならばViewの位置を基準の位置からずらすためのtranslationX、translationYプロパティを更新し、クリックならばperformClickメソッドを呼び出します。クリックおよびドラッグの判定はすべてAndroid SDK標準のGestureDetectorにお任せして、何ピクセル以上移動したらクリックでは無くてドラック操作になるといった複雑なタッチイベント判定処理は独自に書きません。また、タッチしたときのリップルエフェクトにもGestureDetectorのonShowPressコールバックメソッドを使って対応しています。
/** * 閉じたFINE演出の移動 */ private fun setUpDragCloseView() { // View取得の方法はView Binding // 閉じたFINE演出高さ // 高さは可変長ではないのでここで取得。幅はこのタイミングだとテキスト適用前のデフォルトの幅が取得されてしまう val h = binding.fineViewClose.height // 可動範囲の大きさ val parentWidth = binding.fineCloseTouchCatcher.width val parentHeight = binding.fineCloseTouchCatcher.height // ドラッグで移動する val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent): Boolean { // 閉じたFINE演出の上で起きたイベントか確認する val x = binding.fineViewClose.x val y = binding.fineViewClose.y // 閉じたFINE演出幅 val w = binding.fineViewClose.width // ここでtrueを返却すると、onScroll、onSingleTapUpイベントが呼ばれるようになる return x <= e.x && y <= e.y && e.x <= x + w && e.y <= y + h } override fun onShowPress(e: MotionEvent) { // タップ反応エフェクトを表示すべき時に呼ばれる // 閉じたFINE演出の上で起きたイベントか確認する val x = binding.fineViewClose.x val y = binding.fineViewClose.y // 閉じたFINE演出幅 val w = binding.fineViewClose.width if (x <= e.x && y <= e.y && e.x <= x + w && e.y <= y + h) { // リップルエフェクトの中心点を設定する binding.fineViewClose.drawableHotspotChanged( e.x - binding.fineViewClose.x, e.y - binding.fineViewClose.y ) // タップされたときのエフェクトを表示する binding.fineViewClose.isPressed = true } } override fun onScroll( e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { // タップされたときのエフェクトを解除して binding.fineViewClose.isPressed = false // 移動する binding.fineViewClose.translationX -= distanceX binding.fineViewClose.translationY -= distanceY // はみ出さないようにする // まずはみ出しピクセル数を計算 val x = binding.fineViewClose.x val y = binding.fineViewClose.y // 閉じたFINE演出幅 val w = binding.fineViewClose.width val leftOver = 0 - x val topOver = 0 - y val rightOver = x + w - parentWidth val bottomOver = y + h - parentHeight // はみ出していたら戻す if (leftOver > 0) binding.fineViewClose.translationX += leftOver if (topOver > 0) binding.fineViewClose.translationY += topOver if (rightOver > 0) binding.fineViewClose.translationX -= rightOver if (bottomOver > 0) binding.fineViewClose.translationY -= bottomOver return true } override fun onSingleTapUp(e: MotionEvent): Boolean { // タップされたときのイベント処理 // タップされたときのエフェクトを解除して binding.fineViewClose.isPressed = false // 閉じたFINE演出をクリックする binding.fineViewClose.performClick() return true } }) // ロングタップは無効化 gestureDetector.setIsLongpressEnabled(false) // GestureDetectorにタッチイベントを送る binding.fineCloseTouchCatcher.setOnTouchListener { _, event -> gestureDetector.onTouchEvent(event) } }
最後に
前回の記事ではAndroidエンジニアを募集中と締めましたが、おかげさまで採用に成功しました。現在はエンジニア採用を行っていませんが、再び採用を開始することになりましたら、最近リニューアルされたコーポレートサイトや転職サイトに加えてこのブログでも紹介します。
AndroidのCanvasを使ってバーコード読取のファインダーを作り直した話
リサーチ・アンド・イノベーションの高田(tfandkusu)です。こだわりを持ってAndroidアプリ作りができる環境を求めて、今年1月に入社し、CODEのAndroidアプリ開発を担当しています。このたびサードパーティーから買っているバーコードデコードライブラリを別のものに差し替えることとなりましたが、そのライブラリには特徴的な仕様があり、それに合わせてバーコードスキャン範囲を示す表示(以下、ファインダー)にも改修を加えることにしました。この記事ではその部分をCanvasを使って再実装したことを紹介します。
ファインダーをよく見る
ファインダーは下の図で示すとおりです。
- 外側は白い半透明
- 中心に穴が開いている
- 4角に不透明の線がある
- 線には外側だけ陰がある
バーコードデコードライブラリの仕様
バーコードデコードライブラリの仕様ですが、サイズが640x480または480x640の画像のみ対応でした。大きさについては縮小すれば良いですが、縦横比は変えられないのでデコード範囲は縦横比4:3の範囲であると明示する必要があります。よって、カメラプレビューをこのようなルールで切り取ることにしました。
// width、heightはそれぞれカメラプレビューサイズ。 // AndroidのCamera APIの仕様で画面は縦向きでもカメラプレビューは横に倒れている。 val baseHeight = height * (1080 - 96 * 2) / 1080 val baseWidth = baseHeight * 3 / 4 val left = (width - baseWidth) / 2 val top = (height - baseHeight) / 2 val right = (width + baseWidth) / 2 val bottom = (height + baseHeight) / 2
カメラプレビューから取り込まれた画像データはleft,top,right,bottomの位置で切り取られ、480x640に縮小します。余談ですが、このあたりの画像処理はOpenCVライブラリで行っています。過去にはqiitaにOpenCVのサイズを減らす記事を投稿したことがあります。
改修前の実装
改修前の実装はこのようなPNGファイルをImageViewで貼り付けていました。
今回は画面横幅の (1080 - 96 * 2) / 1080、縦幅はその3/4の領域に穴を開けたいので、機種ごとに画面の縦横比が違うと考えると画像は使えません。
CustomViewを使う
画像が使えなければCustom View Componentsを使いCanvasで描画します。
Viewクラスを継承する
CustomViewはViewクラスを継承して作ります。
class CODEViewfinderView : View { /** * ソースコードから追加する用コンストラクタ */ constructor(context: Context) : super(context) /** * XMLから追加する用コンストラクタ */ constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) }
コンストラクタは4種類ありますが、1引数と2引数のものだけを実装すれば良さそうです。 (参考文献)
Viewの大きさとdpを取得する
Viewの大きさはonSizeChangedメソッドが大きさが変わるたびに呼ばれるので、それをオーバーライドして取得します。CODEのバーコード読取画面では大きさが変わらないので最初の1回だけ実行されます。 dpはresourcesプロパティからdisplayMetricsプロパティをたどって取得できます。
override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) { // widthが幅、heightが高さ // 1dpのピクセル数 this.dp = resources.displayMetrics.density // 続きます。 }
描画するビットマップを作成する
描画するビットマップを作成します。大きさはViewのサイズと同じです。Canvasクラスのコンストラクタに渡すことで、そのインスタンスから自由自在に図形などを描いて画像を作れます。
private lateinit var bitmap : Bitmap override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) { // 略 // ビットマップを作成 this.bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) // Canvasを作成 val canvas = Canvas(bitmap) // 続きます。 }
ビットマップに対して描画する
Porter-Duffモードを使いこなす
ファインダーをビットマップに描画します。まず全体を半透明の白で塗りつぶした後に透明を描画して穴を開けています。ここで重要な行はdrawHoleメソッドの paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC)
です。デフォルト設定は PorterDuff.Mode.SRC_OVER になっています。このモードは描画元色のアルファチャンネル(不透明度)を反映した上で描画されます。アルファチャンネル0で黒の矩形を描画しようとしたところ、透明な黒を描画して結果としてなにも描かれません。一方、PorterDuff.Mode.SRC を設定すると、アルファチャンネルを含めてすべての要素を描画元に置き換えます。よって矩形を描いた箇所は穴になります。
/** * 描画情報。onDrawメソッドでも使うので、ここで作る */ private val paint = Paint() override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) { // 略 // 全体を半透明の白で塗りつぶす drawWhole(canvas) // 穴の位置を計算「バーコードデコードライブラリの仕様」節参照 val baseWidth = width * (1080 - 96 * 2) / 1080 val baseHeight = baseWidth * 3 / 4 val left = (width - baseWidth) / 2 val top = (height - baseHeight) / 2 val right = (width + baseWidth) / 2 val bottom = (height + baseHeight) / 2 // 全体を描画 drawWhole(canvas) // あとでここに増えます。 // 穴を描画 drawHole(canvas, left, top, right, bottom) // 続きます。 } /** * まず、全体を薄く白く描画 */ private fun drawWhole(canvas: Canvas) { paint.reset() paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC) paint.color = Color.argb(0xff * 6 / 10, 0xff, 0xff, 0xff) paint.maskFilter = null // 全体を塗る val rect = Rect() rect.left = 0 rect.top = 0 rect.right = bitmap.width rect.bottom = bitmap.height canvas.drawRect(rect, paint) } /** * 穴を開ける */ private fun drawHole(canvas: Canvas, left: Int, top: Int, right: Int, bottom: Int) { paint.reset() // アルファチャンネルをそのまま書く paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC) paint.color = 0x00000000 paint.maskFilter = null val rect = Rect() rect.left = left rect.top = top rect.right = right rect.bottom = bottom canvas.drawRect(rect, paint) }
BlurMaskFilterで陰を描画する
陰がついている4角の線を描画します。
まず陰の元となる形状を作成します。
/** * 穴が空いているところの4角に陰を描く * @param canvas 描画ハンドル * @param left 穴の左 * @param top 穴の上 * @param right 穴の右 * @param bottom 穴の下 */ private fun drawEdgeShadows(canvas: Canvas, left: Int, top: Int, right: Int, bottom: Int) { // 4角の線の長さ val lineLength = (dp * LINE_LENGTH).toInt() // 左上の矩形を描画する val rect = Rect() rect.left = left rect.top = top rect.right = left + lineLength rect.bottom = top + lineLength paint.reset() paint.color = 0xff000000.toInt() canvas.drawRect(rect, paint) // 後で書き換えます //右上、左下、右下も同様に描画する rect.left = right - lineLength rect.top = top rect.right = right rect.bottom = top + lineLength canvas.drawRect(rect, paint) // 後で書き換えます rect.left = left rect.top = bottom - lineLength rect.right = left + lineLength rect.bottom = bottom canvas.drawRect(rect, paint) // 後で書き換えます rect.left = right - lineLength rect.top = bottom - lineLength rect.right = right rect.bottom = bottom canvas.drawRect(rect, paint) // 後で書き換えます }
全然違う形ですが大丈夫です。前述の canvas.drawRect(rect, paint)
について、描画にBlurMaskFilterを使い外側にぼかせるように書き換えます。
paint.color = 0x44000000 paint.maskFilter = BlurMaskFilter(4 * dp, BlurMaskFilter.Blur.OUTER) canvas.drawRect(rect, paint)
これを4角分行った後に、説明が前後していますが、前節のPorter-Duffモードを使った穴の描画を行います。
最後に線を4角の線を合計8本矩形として描画します。
/** * 穴が空いているところの4角に線を書く * @param canvas 描画先Canvas * @param left 穴の左 * @param top 穴の上 * @param right 穴の右 * @param bottom 穴の下 */ private fun drawEdgeLines(canvas: Canvas, left: Int, top: Int, right: Int, bottom: Int) { val lineLength = (dp * LINE_LENGTH).toInt() val lineWidth = (dp * LINE_WIDTH).toInt() // 左上 drawEdgeLine(canvas, left, top, 0, 0, lineLength, lineWidth, 0, 0, lineWidth, lineLength) // 右上 drawEdgeLine(canvas, right - lineLength, top, 0, 0, lineLength, lineWidth, lineLength - lineWidth, 0, lineLength, lineLength) // 左下 drawEdgeLine(canvas, left, bottom - lineLength, 0, 0, lineWidth, lineLength, 0, lineLength - lineWidth, lineLength, lineLength) // 右下 drawEdgeLine(canvas, right - lineLength, bottom - lineLength, lineLength - lineWidth, 0, lineLength, lineLength, 0, lineLength - lineWidth, lineLength, lineLength) } /** * 穴が空いているところの4角の1角分の線を書く * @param canvas 描画先Canvas * @param left 角の左上座標 * @param top 角の上座標 * @param left1 線1の左 * @param top1 線1の上 * @param right1 線1の右 * @param bottom1 線1の下 * @param left2 線2の左 * @param top2 線2の上 * @param right2 線2の右 * @param bottom2 線2の下 */ private fun drawEdgeLine(canvas: Canvas, left: Int, top: Int, left1: Int, top1: Int, right1: Int, bottom1: Int, left2: Int, top2: Int, right2: Int, bottom2: Int) { paint.reset() paint.color = 0xffffffff.toInt() // 線を描画する val rect = Rect() rect.left = left + left1 rect.top = top + top1 rect.right = left + right1 rect.bottom = top + bottom1 canvas.drawRect(rect, paint) rect.left = left + left2 rect.top = top + top2 rect.right = left + right2 rect.bottom = top + bottom2 canvas.drawRect(rect, paint) }
できあがり
最後に作成したBitmapをonDrawメソッドで描画するとできあがりです。
override fun onDraw(canvas: Canvas) { paint.reset() canvas.drawBitmap(bitmap, 0f, 0f, paint) }
求人
リサーチ・アンド・イノベーションでは例えばCanvasを使ってまで細部にこだわりたいAndroidアプリエンジニアを募集しています。
Github の mention を Slack に通知する仕組みを作った part1
概要
リサーチ・アンド・イノベーションの浜田(hamadu)です。
最近、社内では CODE のインフラを さくらのクラウド から Google Cloud Platform へと移行する流れが進んでいます。その一環ではないのですが、Google Clound Functions を使って遊んでいたところ、便利な仕組みが生えたので紹介します。GitHub の Issue や Pull Requestに mention(@(GitHubのユーザ名)
) が来た時に、その人に向けて Slack で通知するというものです。
これがなぜ必要かというと、弊社の開発メンバーでは GitHubのユーザ名とSlackのユーザ名が異なる人が多く、公式の GitHubアプリで mention を上手く通知する機能が無いためです。また、自前で作れば Slack に送るタイミングやメッセージを自由にカスタマイズできる、というメリットもあります。
大まかな仕組みは以下の図のとおりです。
- (1) リポジトリのIssueやPull Requestにコメントがあると、登録しておいた Webhooks により Google Cloud Functions が走る
- (2) Google Cloud Functions ではメッセージのパースをし、必要に応じて
@username
の部分を Slack 向けに書き換える。作ったメッセージを Incoming Webhook の URL に飛ばす - (3) Slack の指定のチャンネルに向けてメッセージが飛ぶ
以下、順番に作成方法を解説します。長くなってしまうので2パート構成で、本稿は前半部分を説明します。
- part1: GitHub Webhooks 経由で Google Cloud Functions が発火する仕組みを作る(上図の (1))
- part2: Google Cloud Functions の中身を作り込み、Slackにメッセージを飛ばせるようにする(上図の (2) と (3))
Google Cloud Functions を使ってみる
まず、GitHub Webhooks の受け先である Google Cloud Functions を設定します。 開発環境のインストールおよび関数のデプロイ方法は 公式チュートリアル にあるので割愛します。
Google Cloud Functions は JavaScript(node) で記述する必要があります。中身はほぼ空で大丈夫ですが、ここではリクエストオブジェクトのヘッダ、および本文を console.log()
で出力させています。
exports.githubToSlack = (req, res) => { console.log(req.headers); console.log(req.body); res.status(200).end(); };
この関数をデプロイすると、関数を発火するためのURLが発行されます。URLは次のような形になっているはずです。(これはデプロイ後、Google Cloud Functions の関数詳細画面 にも表示されます)
https://(リージョン名)-(プロジェクト名).cloudfunctions.net/githubToSlack
ためしに curl
で関数を叩いてみましょう。今回は、Content-Type: application/json
で JSON が飛んでくるように webhooks を設定するので、適当な JSON文字列 を送ってみます。
> curl -X POST -H "Content-Type:application/json" -d '{"test":"hoge"}' (関数のURL)
Google Cloud Functions のログを見て、ヘッダと本文が出力されていれば成功です!
GitHub Webhooks
GitHub Webhooks とは、github.com 上で起こるイベントを HTTP POST で通知する仕組みです。これを Repository や、Organization に対して設定できます。どのイベントに対して発火してほしいかが個別に指定できるので、ここでは Issue, Pull Request に対して発火するように指定します。
連携したいリポジトリの設定画面を開いて、「Add webhook」を押すと設定画面が開くので、以下の様に設定します。特に、Secret は「リクエストが確かにwebhooks経由である」ことを cloud functions に識別させるのに使います。一度設定すると再表示されないので、値をメモっておきましょう。
項目 | 設定値 |
---|---|
Payload URL | cloud functionsのURL |
Content type | application/json |
Secret | 文字列 |
Which events...webhook? | Let me select individual events. |
〃 | Issues, Pull Requestsを選択 |
ここまでできたら一旦テストしておきます。設定したリポジトリで、Issue や Pull Requestを実際に作ってみましょう。Google Cloud Functions の Console にログが出ていれば成功です。
リクエストを検証する
さて、今のままでは URL さえ分かってしまうと何処からでも関数が呼べてしまうので、先程設定した webhook 経由でのみ関数が実行されるようにします。
GitHub は webhook を送る際、リクエスト本文の HMAC を X-Hub-Signature
というヘッダに付与してくれます。この HMAC の秘密鍵となるのが、先程設定した Secret なわけですね。ということでリクエストが正しいか調べるには、同じアルゴリズムを用いて HMAC を計算しヘッダと一致しているか調べればOK。具体的なアルゴリズムは GitHubのドキュメント に書かれています。
HMACの計算及び比較を JavaScript で実行するには crypto の createHmac関数、およびセキュアな比較を行うため secure-compare があればいいでしょう。以下は実装例です。
const crypto = require('crypto'); const secureCompare = require('secure-compare'); function validateRequest(req) { const cipher = 'sha1'; const signature = req.headers['x-hub-signature']; const hmac = crypto.createHmac(cipher, '<Secretで設定した文字列>') .update(req.rawBody) .digest('hex'); const expectedSignature = `${cipher}=${hmac}`; return secureCompare(signature, expectedSignature); }
これでコア部分の実装はできました。しかし、まだ改善できる箇所があります。Secretを直接ソースコードに記述するのをやめて、環境変数に逃しましょう。Cloud function 内で環境変数を使うには、
process.env['VARIABLE_NAME']
のようにします。これは、nodeプロセスで環境変数の値を得る通常の方法です。環境変数に値を入れるには、
> gcloud beta functions deploy FUNCTION_NAME --set-env-vars VARIABLE_NAME=xxx
とします。詳しくはドキュメント Using Environment Variables | Cloud Functions Documentation を読んでください。ここでは、GITHUB_SECRET
という環境変数を使うことにします。
最終的なコードは次のようになりました。
const crypto = require('crypto'); const secureCompare = require('secure-compare'); function validateRequest(req) { const cipher = 'sha1'; const signature = req.headers['x-hub-signature']; const hmac = crypto.createHmac(cipher, process.env['GITHUB_SECRET']) .update(req.rawBody) .digest('hex'); const expectedSignature = `${cipher}=${hmac}`; return secureCompare(signature, expectedSignature); } exports.githubToSlack = (req, res) => { if (!validateRequest(req)) { return res.status(403).send('wrong signature.'); } console.log(req.headers); console.log(req.body); res.status(200).end(); }
ここまで、うまく動いているかテストしておきましょう。Issue および Pull Request を作ると正しく発火すること、curl
で適当なヘッダを付けると落とされることを確認しておきます。
まとめ
長くなってしまいましたが、GitHub <=> Google Cloud Functions 連携の基本的な手順は以上です。ここまでくれば、あとはリクエストの中身を見て、Slack に送る具体的なメッセージを構成するだけになります。これは後日、 part2 にて説明します。
リサーチ・アンド・イノベーションでは、プロダクト開発プロセスそのものをハックしていけるエンジニアを募集中です。
からご応募お待ちしております。
Clojureでブログやスマホアプリを作ってみる part2
こんにちは。リサーチ・アンド・イノベーションの小川(J-ogawa)と申します。
弊社サービスの「あなたの日常を、もっといい日常に変える」そんなアプリCODEの
iPhoneアプリおよびサーバサイドの開発を担当してます。
本記事はシリーズ物で恐縮ですが、Clojureでブログやスマホアプリを作ってみようのコーナー、2回目となります。
前回 Clojureでブログやスマホアプリを作ってみる part1
基本方針
フレームワークにre-frameを使用しています。
re-frameはとてもイケてるフレームワークだと思います。
Elmもre-frameに影響を受けています。
解説記事予定
全4回に分けて解説を行いたいと思います。
前回はre-frameについての概要を解説しました。
今回は、re-frameにおけるイベントについての考え方についてご紹介したいと思います。
dispatch(イベント)における副作用の有無
前回 dispatch -> update db(画面表示用ステート) -> subscribe -> 画面更新
と言う話をしました。
ここについてもう少し詳しく見ていきます。
ここでいうdbは画面表示用の単一のステートを指します
re-frameを使用すると否が応でも処理中の副作用について意識的になるように開発を進めることになります。
ここでいう副作用とは、dbの変更以外の事柄を指します。
まるで「dbを内界」、「それ以外を外界」としているような印象を私は受けました。
副作用なしの場合
dispatchによって、dbを変更します。それだけだと非常に簡単です
その場合、reg-event-db
を使います。
(re-frame/reg-event-db :switch-editor-mode (fn [db [_ mode]] (assoc-in db [:editor-mode] mode)))
第一引数:switch-editor-mode
はこのアクションの名前です。
第二引数(fn [db [_ mode]] (assoc-in db [:editor-mode] mode)
では「現状のdb
とアクションに渡された情報
を引数にして変更後のdbを返す関数」を設定します。
この例は「editor-mode
キーの値を引数mode
の値にしたdb
」を返すようにしています。単純ですね。
副作用ありの場合
例えば、loginのような処理を考えます。
この場合、処理の流れは以下となります。
/login
にhttp POSTして返却値としてaccess_tokenを得る- access_tokenをlocalStorageに保存
/auth
にaccess_tokenを投げて認証する- dbをログイン状態に変更
これはdbを書き換える動作の前に3回の副作用が入っています。
loginで行う一連のコードを追っていく
副作用込みでdbに影響を与える操作はreg-event-fx
を使います。
loginアクションのコードは以下です。
ちょっといきなりゴテっとしたコードが出てきますが、「httpを投げて、成功|失敗
ならどうする」といった内容です。
(この後も流れに沿ってぽんぽんとコードが登場しますが、細部より流れを追っていただけるとと思います)
(re-frame/reg-event-fx :login (fn [{:keys [db]} _] (let [{email :email password :password} (:user-form db)] {:http-xhrio {:method :post :uri "/login" :params {:email email :password password} :format (ajax/json-request-format) :response-format (ajax/json-response-format {:keywords? true}) :on-success [:login-success] :on-failure [:sign-error]}})))
({:keys [○]}
はdestructuringと言う便利な変数バインド機能です。最初は取っつきにくいので読み飛ばしてください。値のdbキー
の中身をdb変数
にバインドしています)
db内のデータ(user-formのemailとpassword)をパラメータとしてPOSTして、成功したらlogin-success
アクションをディスパッチします
次のステップlogin-success
を見てみます
これも、副作用を伴ってdbを書き換える関数なので、reg-event-fx
です。
(re-frame/reg-event-fx :login-success (fn [_ [_ {:keys [token]}]] {:store-token-localstrage token :dispatch [:auth]}))
これの意味は、store-token-localstrage
アクションをやった後に、auth
アクションを行うと言う意味です。
2つのアクションを順に見ていきます。
store-token-localstrage
はdbに影響を与えないシンプルな副作用(?)なのでreg-fx
を使います。
(re-frame/reg-fx :store-token-localstrage (fn [token] (.setItem (.-localStorage js/window) :token token)))
JavaScriptっぽい部分が出てきました。単純に渡されているtokenをlocalstrageの:tokenキーに保存しています。
次のステップauth
を見ます。これもまだ副作用を伴う処理です。GET /auth
してます。
(re-frame/reg-event-fx :auth [(re-frame/inject-cofx :token)] (fn [{:keys [token]} _] {:http-xhrio (-> {:method :get :uri "/auth" :on-success [:auth-success] :on-failure [:auth-error]} wrap-default-http (wrap-token-http token))}))
成功したら:auth-success
アクションをします。ようやく副作用なしのreg-event-db
が来ました。
(re-frame/reg-event-db :auth-success (fn [db [_ res]] (-> db (assoc-in [:auth] true) (assoc-in [:show-login-modal] false))))
dbのデータをログイン後として、画面が書き換わります。
以上の流れを改めて書くと以下になります。
- login (副作用あり)
- login-success (副作用あり)
- store-token-localstrage (副作用あり)
- auth (副作用あり)
- auth-success (副作用なし)
- auth-error
- auth (副作用あり)
- store-token-localstrage (副作用あり)
- sign-error
- login-success (副作用あり)
re-frameではこのように一連の流れを一つ一つに分けること、副作用とdb(画面更新)の処理を明確に区別することを意識させられます。
db外のものを扱う - 副作用の注入(?)
また、auth
アクションではaccess_tokenを処理に使用していますが、これはlocalStorageにあるものなのでdb外となります。
そういったものを扱う場合は副作用を注入するという考え方をします。
もう一度auth
アクションを見てみましょう。
(re-frame/reg-event-fx :auth [(re-frame/inject-cofx :token)] (fn [{:keys [token]} _] {:http-xhrio (-> {:method :get :uri "/auth" :on-success [:auth-success] :on-failure [:auth-error]} wrap-default-http (wrap-token-http token))}))
この関数の3行目、inject-cofx
がそれに当たります。(coはcoeffect:副作用)
外界情報入手用の関数 token
を使用して、外界(localStorage)からtokenをこの世界(db)に注入しています。(http headerにtokenをセットしています)
関数 token
はこんな感じです。localStorageから値を引っ張って来てます。
(re-frame/reg-cofx :token (fn [coeffects _] (assoc coeffects :token (.getItem (.-localStorage js/window) :token))))
と、以上loginの一連の流れとソースを見て来ました。
こう言ったように、re-frameのイベントはdbの内外を非常に意識させるAPIとなっています。
処理を細かく分解して書いていくのはなかなか大変だったりもしますが、こうやって一つ一つが小さくなったものを見るととても追いやすいと感じます。
なにより、こうやって書かざるを得ないので、表現がぶれることが少ないと感じます。
まとめ
re-frameにおけるdispatch(イベント)における副作用の有無について今回は見ていきました。
シリーズもので恐縮ですが、次回はサーバサイドについてざっくりとした解説をしたいと思います。
そしてスマホアプリへ・・
リサーチ・アンド・イノベーションでは、Clojureに限らず技術に興味のあるエンジニアを募集しています。 ご応募お待ちしております。
参考URL
Exponential backoff を実例で理解する
RNI dev の K.oshima です。 network mobility が好きです。
弊社のサービス Mycomment はユーザーのアクセス解析に Woopra と言うサービスを利用しています。 先日、Mycomment と Woopra のデータコレクター間の通信が失敗した結果、ログ監視にエラーが大量に上がってくる事象がありました。
失敗理由は特定データコレクターまでの経路消失です。
どうすればよかったかというと、一度目の通信失敗後にリトライして欲しかった。リトライすればハズレを引かずに成功する可能性があった。Woopra SDK がリトライしてくれればよかった話なのですが、実際にはリトライ機能がないため SDK を利用する利用者(rni-dev)が失敗したらリトライするように書く必要があります。
この待ってリトライするときに、一般によくある実装ではリトライが失敗するたびにリトライ間隔を長くしていきます。 Railsで良く使われる Sidekiq や先日紹介したfaktory でも同じロジックがリトライ制御に使われています。 Mycomment のリトライも同様に実装したいと思います。
なぜそうするのでしょう。
イーサネットの例
きっかけはアプリケーションからの HTTP の利用なのでレイヤー7の話ですが、これからレイヤー1の話をします。 (注意点:レイヤー1の話がそのままレイヤー7にも通じる訳ではないので、そこは分けて考えてください)
古いイーサネット(Ethernet)は 1本のケーブルに複数の機器をつないで通信する実装です。 (今一般的な 1000BASE-T とは違い昔は本当に電線1本だったんです。) AさんもBさんもCさんも同じ1本のケーブルで通信します。
糸電話を想像してください。タコ糸に紙コップを結ぶあれです。
二人だけで糸電話で話すと「もしもし」「どうぞ」と話す側と聴く側に別れて通信します。役割分担がうまくいけば、(ランチにカレーを食べに行こう! などの) 通信は成立します。
3人以上で糸を共有(分岐)して糸電話で話すと、もしAさんとBさんが同時に喋った場合は、同じ糸を分岐して聞いていたCさんはAさんとBさんの声が同時に聞こえて何をしゃべっていたのか聞き取れなくなります。
CSMA/CD
同時に喋ることがあってもうまいこと通信を成立させるルールのひとつが CSMA/CD (Carrier Sense Multiple Access with Collision Detection) です。 (電線の場合は、同時に喋っている人がいるかいないか通信の有無が電圧でわかります。)
1. データを送りたい人はまず受信してみる。
Aさん「(今は誰も話してないぞ)」
2. 他の人が送信中の場合は待つ。 (→ 1. へ) 他の人が送信していない時は、データを送りたい人が送信する。
Aさん「Bさんランチにカレーを食べに行きませんか?」
3. 自分が送信中に、もし他の人も送信しだした時は通信をあきらめて止める。乱数秒待った後に再度送信をトライする。
Aさんが話している途中にCさんも話し出したら、AさんもCさんも黙る。そしておのおのが乱数秒待つので、次にAさんとCさんが話し出すタイミングはズレるはず。
さらに衝突した場合は、n回目のリトライの時は 0
から 2**n
の範囲の待ち時間からランダムに一つ選んで待つ。混んでいると待ち時間が伸びる傾向になる。
下手をすると、誰も送信していない瞬間をずっと待つ必要がありますがいつかは送信できます。こんな簡単なルールでたいていの場合はうまくいきます。
CSMA/CA
他のルールとして、無線 LAN で利用されている CSMA/CA (Carrier Sense Multiple Access/Collision Avoidance) もあります。
1. データを送りたい人はまず受信してみる。
Aさん「(今は誰も話してないぞ)」
2. 他の人が送信していない時は、データを送りたい人が送信する。
Aさん「Bさんランチにカレーを食べに行きませんか?」
3. 他の人が送信している時は、その人の送信が終わるのを待ってすぐに自分が送信すれば、他に送信の順番を待っている人と同時に送信する可能性がある。
BさんもCさんも喋りたい時に、Aさんの話が終わるのを待って終わった瞬間に話し出すとかぶる可能性が高い。他の人の送信が終わって、乱数秒待ってから送信を開始すると他の誰かとかぶりにくい。
どちらも簡単なルールで、全員が待ち状態になるデッドロックも起きないうまいやり方です。ただ、5人や10人が1本のケーブルを利用しているすいている時はうまくいっても、100人1000人が1本のケーブルで話そうとすると大渋滞になります。待ってる時間が長くなって、送信できる機会が減るので、通信速度が遅くなったように見えます。
乱数と待ち時間の延長
待ち時間を決めるのに乱数の要素があるのはなぜか。もし乱数がなければ、1本のケーブル(リソース)を100人が一斉に利用しようとすると、1人が利用開始して残り99人が待ちます。99人は同じ時間待つので次回送信しようとした際に99人の競合になります。乱数で待ち時間が変われば、100人の各々のタイミングがずれてリトライの際に他の誰とも送信がかぶらない可能性があります。リトライの回数が減って待ち時間の合計が減って結果としてスループットが上がる可能性があります。
待ち時間をリトライのたびに長くするのはなぜか。1本のケーブルを複数人で利用するときに空いてくるのを待つための工夫です。一つのリソースを大勢で使うためにはゆずり合う必要があります。ゆずり合うための工夫です。
ここまでの話はざっくりとしたレイヤー1の話です。これがそのまま HTTP 経由のサーバーとクライアントの関係に当てはまるわけではありませんが、(現実には Woopra のデータコレクターは複数 IP ありますし、1個の IP あての通信をロードバランサーで受けて裏側の複数のサーバに負荷分散しているはずです。) 1. 混雑を避ける、2. リトライする 要素は共通です。
メール送信の例
待ち時間をリトライのたびにだんだん長くするテクニックは、email の送信にも使われています。宛先を間違えたメールを送信した場合、SMTP サーバがなんども送信を試して最終的に設定されたリミット(2日など)に引っかかり諦めて送信者に MAILER-DEAMON からの不達メールが返ってくることがあります。SMTPサーバ(sendmail, postfix 等)が再送を試す際にもリトライまでの待ち時間をだんだんと長くする技が使われています。受け手のサーバの処理が溢れてパンクしている時にも送信者(ユーザー)の操作なしに SMTPサーバの陰の働きにより送信可能になる時を待ちメールを配達する工夫です。
以下は、postfix のマニュアルから引用です。
$ man 8 qmgr
http://www.postfix.org/qmgr.8.html
exponential backoff Mail that cannot be delivered upon the first attempt is deferred. The time interval between delivery attempts is dou- bled after each attempt.
TCP プロトコルの例
リトライのたびに待ち時間を長くするアルゴリズムを Exponential back off と呼びます。
TCP/IP の TCP でも Exponential back off は使われています。
以下は NetBSD 5 での TCP 実装ですが、TCP のパケットが届かず再送を要求した時に、 tcp_backoff
を引いて再送待ち時間 t_rxtcur
を伸ばしていくところです。
https://github.com/IIJ-NetBSD/netbsd-src/blob/458bd6ba699726d5269e01d428ee889762fcffbf/sys/netinet/tcp_timer.c#L384
/* * Retransmission timer went off. Message has not * been acked within retransmit interval. Back off * to a longer retransmit interval and retransmit one segment. */ if (++tp->t_rxtshift > TCP_MAXRXTSHIFT) { tp->t_rxtshift = TCP_MAXRXTSHIFT; TCP_STATINC(TCP_STAT_TIMEOUTDROP); tp = tcp_drop(tp, tp->t_softerror ? tp->t_softerror : ETIMEDOUT); goto out; } TCP_STATINC(TCP_STAT_REXMTTIMEO); rto = TCP_REXMTVAL(tp); if (rto < tp->t_rttmin) rto = tp->t_rttmin; TCPT_RANGESET(tp->t_rxtcur, rto * tcp_backoff[tp->t_rxtshift], tp->t_rttmin, TCPTV_REXMTMAX); TCP_TIMER_ARM(tp, TCPT_REXMT, tp->t_rxtcur);
ここの計算。
TCPT_RANGESET(tp->t_rxtcur, rto * tcp_backoff[tp->t_rxtshift], tp->t_rttmin, TCPTV_REXMTMAX);
tcp_backoff
は
const int tcp_backoff[TCP_MAXRXTSHIFT + 1] = { 1, 2, 4, 8, 16, 32, 64, 64, 64, 64, 64, 64, 64 };
とタイムアウトのたびに大きくなり 64 で上限にぶつかる数字になっています。
注釈: 意外に思われるかもしれませんが、TCPの実装は時代によって変化しているので、OSの種類やバージョンによってはこの再送待ちの挙動は異なります。
QUIC プロトコルの例
TCP の欠点をつぶそうとしている新しいプロトコル QUIC の例として、 Google Chrome の QUIC 実装を見てみましょう。
パケット送信する部分で、再送待ちタイマーの値を計算するところ。
consecutive_rto_count_
分再送の待ち時間を長くしていきます。
https://chromium.googlesource.com/chromium/src/net/+/master/quic/core/quic_sent_packet_manager.cc#830
// Calculate exponential back off. retransmission_delay = retransmission_delay * (1 << std::min<size_t>(consecutive_rto_count_, kMaxRetransmissions));
このように Google の QUIC プロトコルでも exponential back off アルゴリズムは使われています。
ここに使いたい
発端となった Woopra SDK で再送信する時に待ち時間を伸ばす理由は、メール送信と同じです。
相手が受け取れなかったのなら再送信したい。しかし、即再送信しても相手のサーバ負荷が原因だった場合は再び受け取れない可能性が高い。少し待ってから再送信したい。その「少し待って」の時間はどうやって決めるの? 失敗する間はリトライの待ち時間を徐々に伸ばしていって、相手が受け取れるタイミングを待つために exponential back off を使います。
自サイトの(Railsアプリごとの)複数の送信が相手の負荷を再び高めないように、乱数秒追加して再送タイミングもばらけさせるとなお親切ですね。通信相手にも負担にならないし、失敗の原因を一つ減らすので自分のサービスにもプラスです。
お待ちしております
上のような話に興味を持たれる方。そんなの知っているよ、 Linux 4.15 では違うよ。どちらの感想を持つ方も RNI ではお待ちしております。