クラシル開発ブログ

クラシル開発ブログ

手軽になめらかUI/UXを実現したい〜Material ComponentsのProgressIndicatorを使ってみた〜

ogp

こんにちは。dely開発部にてクラシルのAndroidエンジニアを担当しているnozaです。 月日の流れは早いもので、前回の記事から間があいてしまいましたね。

先月、Material Components for Androidのバージョン1.3.0*1が公開されましたね。 主な内容として下記のComponentが追加されてます。

  • MaterialTimePicker
  • ProgressIndicator

個人的にはProgressIndicatorを待ち望んでいました。 というのも、Android SDKで提供されているProgressBarだと、思った通りに手軽にプログレス表示するのが難しかったからです。 今回はProgressBarのモヤっとポイントと、ProgressIndicatorだとどんなプログレス表示が実現できるのかを紹介していきます。

ProgressBar*2

Android SDKで提供されるもので、標準の設定で円形にしたり水平バーにしたりできます。 画面内のコンテンツを取得するための通信中などに用いることが多いと思います。

Android版クラシルでは、画面内のコンテンツ表示の邪魔をしないようにToolbarの下にバーの形状で設置しようとしました。

f:id:nozakichi:20210303130527g:plain
こんな感じにしたいの図

しかしながら、お手軽に実現できないモヤっとポイントがいくつかあったのです。

ProgressBarのモヤっとポイント

バーの上下の余白

indeterminate=trueの場合、標準の設定で下記のようなアニメーションが実現できます。

f:id:nozakichi:20210302185111g:plain

しかし、このアニメーションで設定されるVectorDrawable(API Level21未満だとpng画像)自体に上下の余白が入っています。 Viewの範囲をわかりやすくするために背景色#ddddddを入れてみると・・・

f:id:nozakichi:20210302223533p:plain

このようにViewの描画領域とバーの間に余白があることがわかります。

意図した場所に配置するためにはこの余白を考慮して工夫する必要がありました。 工夫の例をいくつか挙げてみます。

例1:自前でDrawableを用意して、余白をコントロールする。

標準で用意されているDrawableに余白が含まれているなら、自前で作成してしまえば良いです。 が、VectorDrawableやアニメーションなど用意するものが多く、ある程度知識も必要になるため面倒くさいです。

progressDrawableindeterminateDrawableというattributeで設定可能

例2:上下のマージンに程よいマイナス値を設定してごまかす

<ProgressBar
    style="@style/Widget.AppCompat.ProgressBar.Horizontal"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="-6dp"
    android:layout_marginBottom="-6dp"
    android:indeterminate="true" />

marginにマイナス値を設定することで余白分を無くしています。 が、小手先で対応している感じがあってモヤっとします。

例3:layout_heightとscaleYでごまかす

<ProgressBar
    style="@style/Widget.AppCompat.ProgressBar.Horizontal"
    android:layout_width="match_parent"
    android:layout_height="4dp"
    android:indeterminate="true"
    android:scaleY="4" />

高さ4dpにすることで、バーの表示が縮んでしまうのでscaleY="4"で引き伸ばしています。

f:id:nozakichi:20210302224253p:plain

上下に余計な余白は入っていませんが、縮めて引き伸ばしているためボケてしまっています。 かっこ悪いですね。

といった感じで、お手軽でしっくりくる解決ができずモヤっとしていました。

表示/非表示時の切り替え

例えば通信中にはProgressBarを表示し、通信完了したら非表示にしたいと思います。 よくやる方法としてはvisibilityを操作する方法ですが、それだとパッと切り替わってしまうためチカチカした印象になります。

f:id:nozakichi:20210303132739g:plain
通信状態に応じてProgressBarが突然出たり消える例

突然何かが表示されたり消えたりすると潜在的な違和感を与えてしまうので、もっと滑らかに出たり消えたりさせたいものです。 表示状態を切り替える時にアニメーションをうまいこと実行させるのも手ですが、お手軽に実現したいというモヤっと感がありました。

そこで、Material ComponentのProgressIndicatorの出番です。


Material ComponentのProgressIndicator

前述したモヤっと感を、Material ComponentのProgressIndicatorを利用して解決してみましょう。 水平バーで表現できるLinearProgressIndicatorを使います。

 <com.google.android.material.progressindicator.LinearProgressIndicator
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:hideAnimationBehavior="outward"
        app:showAnimationBehavior="inward"
        app:indicatorColor="#ffaa4e"
        app:trackColor="#eeebe7" />

上記のxmlで定義したLinearProgressIndicatorをlinearProgressIndicatorという変数名で取得しておきつつ・・・

// 表示したい時に呼び出す
linearProgressIndicator.show()

// 非表示にしたい時に呼び出す
linearProgressIndicator.hide()

※Material Componentの導入*3は省略しています。

f:id:nozakichi:20210303130527g:plain
LinearProgressIndicatorで実装した例

はい、これだけでできました! 滑らかで美しい!

ProgressIndicatorで設定可能なattributes

Material DesignのProgress indicators*4にどんな風に扱えるのか記載がありますが、どんな見た目になるのかやってみましょう。

進捗を表す部分はindicatorColorで、その下地はtrackColorで色を設定できます。

f:id:nozakichi:20210302230200p:plain

また、indicatorColorには色の配列を設定可能です。

<!-- colors.xml に下記を定義 -->
<array name="progress_colors">
    <item>#f00</item>
    <item>#ff0</item>
    <item>#0f0</item>
    <item>#00f</item>
</array>


<com.google.android.material.progressindicator.LinearProgressIndicator
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:indeterminate="true"
    app:indicatorColor="@array/progress_colors" />


indeterminateな水平バーを使用する場合、indeterminateAnimationTypeというattributeで、色配列がどのように適応されるのかを指定できます。

indeterminate
AnimationType
見た目
disjoint f:id:nozakichi:20210303133005g:plain
contiguous f:id:nozakichi:20210303133104g:plain

ただし、"contiguous"という設定を使用するには条件がいくつかあるので注意です。

  • trackCornerRadius(後に説明)は設定不可
  • indicatorColorで設定した色が3色以上
    • 配列が3つ以上でも、色が3色以上である必要がある

※設定を無視してくれればいいのですが、レイアウトxmlの読み込みでエラーになりクラッシュしました。



trackThicknessで高さ(太さ?)を、 trackCornerRadiusで、indicator部分に丸みを設定できます。

"丸み"とはなんなのかは、実際の表示をみてもらうとわかりやすいです。 (よりわかりやすくするためにtrackThicknessを1増してます)

見た目
trackCornerRadius設定なし f:id:nozakichi:20210302231927g:plain
trackCornerRadius設定あり f:id:nozakichi:20210302232021g:plain



表示アニメーション

表示時はapp:hideAnimationBehavior、非表示時はapp:showAnimationBehaviorのattributeで設定できます、 ProgressIndicatorは標準ではnoneが設定されていて、アニメーションがありません。

設定値 非表示時(hide) 表示時(show)
none 標準で設定されているもの。アニメーションしない。 標準で設定されているもの。アニメーションしない。
outward 下端から上端に向かって折りたたまれる 下端から上端まで拡大
inward 上端から下端に向かって折りたたまれる 上端から下端まで拡大

それぞれを設定してみた時の見た目はこんな感じです。

hide show 見た目
outward outward f:id:nozakichi:20210303133150g:plain
outward inward f:id:nozakichi:20210303133303g:plain
inward outward f:id:nozakichi:20210303133327g:plain
inward inward f:id:nozakichi:20210303133215g:plain

ちなみに表示アニメーションが実行された後はvisibilityが変更されます。 show()の後にはView.VISIBLEが、hide()の後にはView.INVISIBLEView.GONEとなりますが、これはsetVisibilityAfterHide(int visibility)というメソッドで設定できます。



アニメーション方向

標準では左から右へ満たされたり流れたりするアニメーションですがapp:indicatorDirectionLinearというattributeで変更可能です。

determinateな場合

indicatorDirectionLinear 見た目
leftToRight f:id:nozakichi:20210303133422g:plain
rightToLeft f:id:nozakichi:20210303133453g:plain

indeterminateな場合

indicatorDirectionLinear 見た目
leftToRight f:id:nozakichi:20210303133523g:plain
rightToLeft f:id:nozakichi:20210303133543g:plain

startToEndendToStartも設定可能だが省略 ※LinearProgressIndicatorだけに用意されたattribute

他にもCircularProgressIndicator用に用意されたattributeもあるのですが、もりもりになってきたのでひとまずここまで。



ProgressBar、ProgressIndicatorの使い所

ProgressIndicatorの紹介をしてきましたが、どんなプログレス表示をしたいかによってProgressBarを使うのか、ProgressIndicatorを使うのかは変わってくると思います。

ProgressIndicatorはMaterialDesignな見た目の表示はお手軽に実装できますが、独自のアニメーション(例えばロゴがグルグル回ったり跳ねたり・・・)を設定するattributeは用意されていません。 凝ったプログレス表示をしたい場合は、Drawableを自前で作成してProgressBarに適応した方が良さそうです。 筆者は今回ProgressBarの中身も覗いてみたのですが、アニメーションの設定とか楽しそうだなと思いました。



おわりに

MaterialComponentのProgressIndicatorについて紹介してきましたが、Android版クラシルでも実際に導入しています。 Android版クラシルでは、読み込み中の表示から通信中、コンテンツ取得後の表示までがなめらかでユーザーの目に優しいものになっていると思います。 それはサービスに大きく関わるような効果ではありませんが、アプリ全体を通じて「安心感」や「温かみ」のようなものをユーザへ与えられているのではないかなと思います。

極端な例を用意してみましたが、どう感じますか?

f:id:nozakichi:20210303133607g:plain f:id:nozakichi:20210303133645g:plain

新しい機能を通じてユーザーに最適で最大の価値を提供する事はもちろん大切なことですが、このような細かなところへの配慮を重ねることでも快適さを実現していきたいですね。

なぜ MVVM + FRP は Elm Architecture に勝てないのか

こんにちは、delyでクラシルiOSアプリ開発を担当している稲見 (@inamiy)です。 この記事は「dely #2 Advent Calendar 2020」の25日目の記事です。

昨日は、delyのSREチームのjoooee0000(高山)さんによる delyのSREチームがオンコールトレーニングを導入する3つの理由 の記事でした。 オンコール対応できるエンジニア、強くてカッコいい・・・

私の方からは、メリークリスマス🎄🎅🔔 にふさわしい Elm Architecture による unidirectional なプレゼントをお届けします🎁

(2020/12/26 EDIT: タイトルを「なぜ MVVM は Elm Architecture に勝てないのか」から「なぜ MVVM + FRP は Elm Architecture に勝てないのか」に変更しました)

iOS開発における MVVM + FRP

2020年現在、多くのiOSアプリ開発の現場では、RxSwift等を用いた関数型リアクティブプログラミング (Functional Reactive Programming, FRP) によるMVVM (Model-View-ViewModel) 設計が主流だと思います。

MVVMは、テストしにくい UIViewController からアプリの表示ロジックを別クラス (ViewModel) に切り出し、複雑なビュー構造(=可変参照と副作用の悪夢)から解放されて、コードの可読性の向上と、テストをよりシンプルに書くための基本的な設計パターンとなっています。

従来のiOS開発では、ViewController〜ViewModel 間のデータのやり取りについて、

  • データの保持に「可変状態 (ViewModel内のvar変数など)」
  • データの送信に「委譲(デリゲート)」や「コールバック」

を使った方法が考えられてきました。

// Before: 従来のViewModel
public class ViewModel {
    // 可変状態
    public var state: String
    
    // コールバック付き関数
    public func doSomething(callback: (String) -> Void) {
        let result = state + " world" // 何か計算する
        callback(result) // コールバックで返す
    }
}

// Before: 従来のViewController
class ViewController: UIViewController {
    let viewModel: ViewModel
    ...
    
    func viewDidLoad() {
        ...
        viewModel.state = "hello" // 1. 状態をセットする
        
        // 2. ビュー側から手動でメソッドを呼び出して、計算結果をコールバックで受け取る
        viewModel.doSomething(callback: { text in
            print(text) // 3. hello world が出力される
        })
    }
}

しかし、FRPの登場によって、これまでの pull方式(状態の更新とコールバックの呼び出しタイミングが異なる2ステップ)から push方式(状態の更新と同時にコールバックが自動的に呼び出される1ステップ)に一変しました。 可変状態として BehaviorRelay (BehaviorSubject)、コールバックとしてそのストリーム機能部分である Observable が用いられるようになりました。

// After: FRPを使ったViewModel
public class ViewModel {
    // 可変状態
    public let state: BehaviorRelay<String>
    
    // ストリーム出力
    public func doSomething() -> Observable<String> {
        return state.asObservable().map { $0 + " world" }
    }
}

// After: FRPを使ったViewController
class ViewController: UIViewController {
    let viewModel: ViewModel
    ...
    
    func viewDidLoad() {
        ...
        
        // 1'. 先に購読(監視)して準備する
        viewModel.doSomething().subscribe(onNext: { text in
            print(text) // 3'. hello world が出力される
        })
        
        // 2'. 状態をセットすると、3が自動的に呼ばれる(同じコールスタック内)
        viewModel.state.accept("hello")
    }
}

一見すると、1と2の順序が逆転しただけに見えますが、After の2' を毎回呼び出す度に、3' 内の購読のクロージャが自動的に呼び出される のに対して、Before 1 の場合は何度呼び出しても、追加で 2 を呼ばないと最新の状態を用いた計算を実行することができません。 つまり、 FRPの購読機能(オブザーバーパターン)によって、メソッドを毎回手動で呼び出す手間が省ける ことがFRPの利点の一つです。

iOSのUI開発においては、ビュー側でViewModel内の表示用データの Observable を購読することによって、データバインディングという形で「1回の購読だけでUIの連続更新が可能」になります。

以降の話では、「iOS開発における MVVM + FRP パターン」をまとめて「MVVM」と呼ぶことにします。

一般的なViewModel

上述の2つのコードは、ViewModel内部の状態がどちらも public なので、外部から直接アクセスできてしまう=将来の状態の予測可能性が簡単に壊される懸念があります。 通常の開発では、内部状態を private に隠蔽して、代わりに PublishRelay 等を用いた入力用のデータフローを追加することが一般的です。

// After 2: FRPを使ったViewModel (状態隠蔽)
public class ViewModel {
    // private な可変状態
    private let state: BehaviorRelay<String> = .init(value: "!!!")
    
    // public な入力
    public let input: PublishRelay<String>
    
    // ストリーム出力
    public func doSomething() -> Observable<String> {
        return input.asObservable()
            // 入力をトリガーに、現在の状態を取得
            .withLatestFrom(state) { input, state in
                return input + "world" + state
            }
    }
}

class ViewController: UIViewController {
    let viewModel: ViewModel
    ...
    
    func viewDidLoad() {
        ...
        
        viewModel.doSomething().subscribe(onNext: { text in
            print(text) // hello world!!! が出力される
        })
        
        viewModel.input.accept("hello")
    }
}

この頻出パターンでは、状態が withLatestFrom 経由で取得された簡易的なデータフローとなっていますが、ここに(UI)アーキテクチャーを考える上での本質情報が隠されています。

それは、ViewModelの基本的な役割が 「入力ストリームと内部状態をもとに、新しいストリームを外部に出力する」 ということです。 「入力」と「内部状態」、そして「出力」という3つの要素は、まさに計算機理論の基礎的モデルであるステートマシン(状態機械)そのもの といえます。

複雑化しすぎたViewModel

しかし残念ながら、多くのiOS開発現場におけるMVVM設計は、このような単純な作りにはなっていません。 もちろん、業務が発展していくに従って、ビジネスロジックが複雑にならざるを得ない事情がありますが、私たちiOS開発者が FRPを過信しすぎて複雑なデータフローを構築してしまう ことにも大きな問題があります。

具体的な例を挙げると、ViewModelはしばしばこのように肥大化しがちです:

// After 2: FRPを使いすぎたViewModel
public class ViewModel {
    // private な可変状態の集まり
    private let state1: BehaviorRelay<String>
    private let state2: BehaviorRelay<String>
    private let state3: BehaviorRelay<String>
    private let state4: BehaviorRelay<String>
    private let state5: BehaviorRelay<String>
    ...
    
    // public な入力ストリームの集まり
    public let input1: PublishRelay<String>
    public let input2: PublishRelay<String>
    public let input3: PublishRelay<String>
    public let input4: PublishRelay<String>
    public let input5: PublishRelay<String>
    ...
    
    // public な出力ストリームの集まり
    public let output1: Observable<String>
    public let output2: Observable<String>
    public let output3: Observable<String>
    public let output4: Observable<String>
    public let output5: Observable<String>
    ...
    
    // 初期化と同時にデータフローのグラフを構築
    public init() {
        // output1 は input1 と state1 に依存
        output1 = input1.asObservable()
            .withLatestFrom(state1) { input, state in
                return input + "world" + state
            }

        // output2 は、input2 と state1, state2, state3 に依存
        output2 = input2.asObservable()
            .withLatestFrom(state1) { ($0, $1) }
            .withLatestFrom(state2) { ($0, $1, $2) }
            .withLatestFrom(state3) { ($0, $1, $2, $3) }
            .flatMap { ... }

        // output3 は、input3、input4 と output2 も使いつつ、state4 に依存
        // 何なら追加で別の副作用も同時に行う
        output3 = Observable.combineLatest(input3, input4, output2)
            .map { ... } 
            .withLatestFrom(state4) { ... }
            .flatMap { ... }
            .do(onNext: { ... })
        
        // output4 は、以下略
        ...
        
        // 結果的に、withLatestFrom, combineLatest等を多用した、
        // 入力と状態が複雑に絡み合うカオスなデータフローのグラフが出来上がる
    }
}

これはあたかも、多層なニューラルネットワークを頑張って一から手書きしているようなものです。 init() 内部のコードがFRPのパイプラインで埋め尽くされ、数百行のコードに膨れ上がることも少なくありません。

このようなFRPの過剰使用とコードの複雑化のことを、個人的に リアクティブ・スパゲティ と呼んでいます。 (もちろん、FRPが存在しなかった頃に比べれば、パイプライン化によって可読性は随分と高まった方なのですが)

なぜリアクティブ・スパゲティは起きるのか

リアクティブ・スパゲティが発生する原因は明確です。 「(いつの間にか)state2input2output2 が生え始めた」 からです。

個々の Observable が存在することは、それぞれがデータフローを増やしてしまう要因になります。 そして、チーム全体を通してFRPをよく理解していないと、簡単にストリームの分岐や合流、余分に追加された状態とその手動ハンドリング(例:disposeBag 以外の Disposable をViewModelが所有している)が大量に発生してしまい、循環的複雑度が爆発的に増加します。

よりコードの可読性を高く保ち、簡潔に書くためには、入力・内部状態・出力それぞれが一本化されてシンプルさを維持しなければなりません。

Elm Architecture

ここで、Elm というプログラミング言語に目を向けてみましょう。

細かい言語仕様についてはここでは触れませんが、フロントエンドエンジニアの方であれば、The Elm Architecture を知っている方も多いと思います。 プログラミング言語全般のUI設計思想に大きな影響を与え、JavaScript Redux、Swift Composable Architecture、Rust Yew、PureScript Halogen、Haskell Miso など、事例を挙げると枚挙に暇がありません。

ざっくり言うと、Elm Architecture は「入力 Msg と現在状態 Model から次の状態 Model を出力する」という基本に忠実な Unidirectional UI設計のことです。 純粋関数である update(または Redux でいう reducer)関数を定義し、プログラム実行の際に使用します。

// Swiftで書いた例
enum Msg { case increment, decrement }
typealias Model = Int

// update : msg -> model -> model
func update(msg: Msg, model: Model) -> Model {
    switch msg {
    case .increment: return model + 1
    case .decrement: return model - 1
    }
}

プログラムの実行については、次のような形の関数を呼び出します:

/*
sandbox :
    { init : model
    , view : model -> Html msg
    , update : msg -> model -> model
    }
    -> Program () model msg
 */
func makeProgram(
    init: Model,                  // 初期状態
    view: Model -> Html<Msg>,     // ビューのレンダリング
    update: (Msg, Model) -> Model // reducer
) -> Program<Model, Msg>

FRP 時代の Elm Architecture (〜v0.16)

ところで、Elm Architecture は v0.17 以前にはFRPを使っていた ことをご存知でしょうか?

Signal と呼ばれる、おおよそ RxSwift.BehaviorRelay (BehaviorSubject) と同じデータ構造を使って、副作用を含むイベントのストリームをパイプライン処理していきます。

そのElm + FRP時代のオペレータの中でも特に有名なのが foldp (fold from the past) と呼ばれる、過去を畳み込む関数です。

// foldp : (a -> state -> state) -> state -> Signal a -> Signal state
func foldp<Input, State>(
    update: (Input, State) -> State, 
    initial: State
) -> (Signal<Input>) -> Signal<State>

「過去を畳み込む」というと、なんだか中二心がくすぐられる思いがしますが、なんてことはない、RxSwift.scan と同じ意味です。

実はこの foldp は、前節の MVVM と同じく、「入力ストリームと内部状態をもとに、外部に新しいストリームを出力する」 という計算のエッセンスが随所に散りばめられています。

  • update: (Input, State) -> State:畳み込み計算 (= reducer)
  • initial: State:初期状態
  • Signal<Input>:単一の入力ストリーム
  • Signal<State>:単一の出力ストリーム

(NOTE: この出力ストリームはその後、 makeProgram 内で view を使って Signal<Html> に変換されて画面に出力されます) (NOTE: (Signal<Input>) -> Signal<State> の部分を Signal Function と呼び、 Arrow と呼ばれる構造を持ちます= Arrowized FRP。これがいわゆる Mealy Machine の話へとつながります)

従来のFRPでは、「入力・内部状態・出力」のエッセンスを実現するために、FRPパイプラインを真面目に実装する必要がありました。 一方で、foldp が教えてくれるのは、 複雑奇怪なパイプラインを作る代わりに update 関数一つを用意 すればそれで済むということです。 これが、Elm v0.17 で A Farewell to FRP になったきっかけとも言えます。

MVVM vs Elm Architecture

それでは早速、MVVM と Elm Architecture を比較していきましょう。

今回は話を簡単にするため、MVVMの場合の入力と出力のストリームがそれぞれ2つずつあると仮定します。 また、各 Observable ストリームは無限時間存在するものとし、 onError / onCompleted を行わないものとします。

すると、Elm Architecture (foldp) によるストリームの一本化の場合、 MVVMのような Observable を複数構成する代わりに、一本化された Observable の型パラメータに入る「状態」と「アクション」の型を複数に細分化する構造を取る ようになります。

// アプリ全体の2アクション
// Action ≅ Action1 + Action2 。足し算はEitherで書くことができる。
enum Action { 
    case action1(Action1) // 子アクション1
    case action2(Action2) // 子アクション2
}
// アプリ全体の2状態
// State ≅ State1 × State2 。掛け算はタプルで書くことができる。
struct State {
    var state1: State1 // 子状態1
    var state2: State2 // 子状態2
}

通常、アクションは enum (直和型)、状態は struct (直積型)を使う場合が多いので、一旦その形にならうものとします。

直和型と直積型については、簡単に言うと、 「代数的データ型 = 型で足し算と掛け算ができる」 というものです。 足し算は Either 型、掛け算はタプル型 だと考えることができます。

代数的データ型 (Algebraic Data Type = ADT) の詳細はこちらをご参考下さい。

ここまでの話を一旦整理すると、

  • MVVM では複数のObservableを入力・出力に持つ
    • 複数を表現するこの場合、掛け算(タプル)で考える
  • Elm (foldp) では、入力・出力に1つずつの Observable のみを使い、その値を代数的データ型で細分化する
MVVM Elm
入力 Obs<Action1> × Obs<Action2> Obs<Action>
≅ Obs<Aciton1 + Action2>
出力 Obs<State1> × Obs<State2> Obs<State>
≅ Obs<State1 × State2>

(Obs は Observableの略)

Observable の代数的性質

ここで天下り式になりますが、 Observable の重要な性質として、以下のことが成り立ちます。 (ここでは、データフロー=川と呼ぶことにします)

Obs<A> = Aが流れる川
Obs<B> = Bが流れる川

Obs<A> × Obs<B> = Aが流れる川と、Bが流れる川

は、一つにまとめて、

Obs<A + B> = AまたはBが流れる川

に置き換えることができる(その逆も成り立つ)

この仮説は直感的にも正しそうに見えますね。 証明は、片方からもう一方に変換するコードを実装できるかどうかで決まります。

// Obs<A> × Obs<B> → Obs<A + B>
func fromMvvmToElm<A, B>(a: Observable<A>, b: Observable<B>) -> Observable<Either<A, B>> {
    return Observable.merge(a.map(Either.left), b.map(Either.right))
}

// Obs<A + B> → Obs<A> × Obs<B>
func fromElmToMvvm<A, B>(aOrB: Observable<Either<A, B>>) -> (Observable<A>, Observable<B>) {
    return (
        aOrB.compactMap { 
            if case let .left(a) = $0 { return a } else { return nil }
        },
        aOrB.compactMap { 
            if case let .right(b) = $0 { return b } else { return nil }
        },
    )
}

この相互の関係性から分かることとして、2つの関数を交互に呼ぶと

  • fromElmToMvvm(fromMvvmToElm(a, b)) = (a, b)
  • fromMvvmToElm(fromElmToMvvm(aOrB)) = aOrB

と、どんな入力値を代入しても元の入力値に戻ります。 この「相互変換して元に戻せる」性質のことを 同型 (isomorphic) といい、

Obs<A> × Obs<B> ≅ Obs<A + B>

と書くことができます(「≅」はイコールではなく、同型を意味します)

結局、何が言いたいかというと、

MVVM Elm
入力 Obs<Action1> × Obs<Action2> Obs<Action>
≅ Obs<Action1 + Action2>

MVVM と Elm の「入力」の構造については、どちらも同じことを言っているに等しい と結論付けることができます。

余談: Observable は単なる「足し算の型の圏」から「掛け算の型の圏」への強モノイダル関手だよ

なお、余談ですが、Observable<Never> についても思いを馳せると、面白いことに気づきます。 Observable<Never> は(今回の前提においては)「無限に続く絶対に値を流さないストリーム」を意味しますが、実際に実装してみると:

let never: Observable<Never> = Observable.create { observer in
    // 何もしない、というかできない
    return Disposables.create()
}

の書き方一通りしかありません。(Observable.never と同じ)

一方で、Swiftの Void (Unit型) もまた () ただ一つのみを値として持ちます。 つまり、 Observable<Never> ≅ Void が成り立ちます。

ここで、おもむろに圏論(数学)という飛び道具を持ち出すと、1 = Void, 0 = Never とおいて、

  • μ: Obs<A> × Obs<B> → Obs<A + B>
  • ε: 1 -> Obs<0>

が同型射(逆方向の関数もある)であることから、Observable が モノイダル圏 (型, +, 0) から (型, ×, 1) への強モノイダル関手 (strong monoidal functor, nLab) であることが分かります。

何を言っているのか良く分からないかもしれませんが、要するに Observable は強かったのです。

出力ストリームの合成の限界

さて、入力に関して MVVM と Elm Architecture の構造は同じであることが分かりました。 それでは、出力についてはどうでしょうか?

MVVM Elm
出力 Obs<State1> × Obs<State2> Obs<State>
≅ Obs<State1 × State2>

結論を先に言ってしまうと、出力は 相互に変換して元に戻すことができません。

試しに、変換関数として次の実装を考えてみましょう。

// 1. Obs<A> × Obs<B> → Obs<A × B>
func fromMvvmToElm<A, B>(a: Observable<A>, b: Observable<B>) -> Observable<(A, B)> {
    return Observable.combineLatest(a, b) { ($0, $1) }
}

// 2. Obs<A × B> → Obs<A> × Obs<B>
func fromElmToMvvm<A, B>(ab: Observable<(A, B)>) -> (Observable<A>, Observable<B>) {
    return (ab.map { $0.0 }, ab.map { $0.1 })
}

一見すると、この対応関係は成り立ちそうに見えますが、残念ながら上手くいきません。 例えば、Rx marble diagram で適当なデータフローを考えてみると:

a  : a0--a1----------a2-->
b  : b0------b1--b2------>

// fromMvvmToElm(a, b)
ab : a0--a1--a1--a1--a2-->
     b0  b0  b1  b2  b2

// fromElmToMvvm(fromMvvmToElm(a, b))
a' : a0--a1--a1--a1--a2-->
b' : b0--b0--b1--b2--b2-->

a != a', b != b' なので元に戻らないことが分かります。

他にも combineLatestzipwithLatestFrom などの他の合成オペレータに置き換えたり、 distinctUntilChanged 等を用いてフィルタリングしてもロジックが複雑化するのみで、同型であることを導くことは困難です。

2.の実装がとても自然なストリーム分解の導出に見える一方、1.の実装で 2つのObservableの(掛け算を使った)合成による不可逆性が発生している と言えます。

この原因の根本について、筆者は次のように考えます:

  • combineLatestzip, withLatestFrom 等を使った Observable の掛け算の合成は、(時間的同期を取るために)内部で発行した値を一部メモリキャッシュするという「副作用」が発生し、これがFRPのストリームの計算結果に対しても不可逆性を生じさせている

もし、この仮説が正しいとするなら、次の点についても言えそうです:

  • fromElmToMvvm は自然な導出(純粋な map のみを使っているので)
    • Elm Architecture から MVVM への出力の変換は容易
    • MVVM への出力変換で、各々のストリームが前回の値を重複して発行してしまう問題があるが、distinctUntilChanged を使えば、MVVMのように差分更新のみを抽出することも可能
  • fromMvvmToElm は、どのような掛け算的合成を行っても、不可逆な結果に終わる
    • MVVMからElmを構成することはできない

Q. combineLatest を使えば、全ての出力をかき集められるのでは?

ところで、上述の 「MVVMからElmを構成することはできない」は、やや飛躍した結論に思われるかもしれません。 実際、数学がどうこうという謎めいた話を無視すれば、combineLatest を使って散らばった各出力の Observable を一点に集めることが可能だからです。

しかし、この単純な方法は「限られたケース」においてのみ可能であるだけで、一般的には成立しませんし、また非効率的です。 主に、次のような課題があります。

  • combineLatest 引数の各 Observable は subscribe 時点で 初期値を持っていなければならない
    • 初期値がないと、combineLatestonNext がなかなか始まらず、Elmにおける状態更新のタイミングを再現しない
  • combineLatest は、掛け算的合流計算のために メモリキャッシュを消費 し、Elm に比べて非効率になりやすい
  • Reactive glitch(同じ上流元の同期的合流問題)
    • 2つの異なる出力が、同じ1つの入力をトリガーとして派生した場合、タイミング問題が生じて、中間の変更状態が反映される

ちなみに Reactive glitch の根本問題は、 2つの出力が「同時に更新」される場合に、個々の Observable に分解できない ことが原因です:

// aとbが両方同時に更新
ab : a0------a1-->
     b0      b1

// fromElmToMVVM(ab)
a  : a0------a1-->
b  : b0------b1-->

// fromMvvmToElm(fromElmToMvvm(ab))
ab': a0-----a1-a1-->
     b0     b0 b1

一瞬だけ (a1, b0) (場合によっては (a0, b1))という余計な中間状態が発生している ことが分かります。 この問題点として、もし ab' をVirtual DOMに渡してUI差分レンダリングした場合、不要な計算が走ることにつながります。

Reactive glitch に対する解決策としては、FRPの中で トポロジカルソート を用いたQueueによる管理の方法が挙げられます。 詳しくは、こちらのURLをご参考下さい。

なお、RxSwift や ReactiveSwift を始めとする、ほとんどのFRPライブラリは、Reactive glitch 問題に対応していません。 もし対応しているフレームワークがあれば、ぜひ教えて下さい。

まとめ

いかがでしたか?

この記事では、MVVMに対するElm Architectureの優位性について、FRP (Observable) の持つ数学的構造 に着目して仮説を立ててみました。 Virtual DOM (差分レンダリング) フレームワークの有無や良し悪しに関係なく、結論を導ける という点が、この話の一番の面白い点だと思います。

さらに学びたい方のために、Swiftで解説した Elm Architecture について、こちらのスライドをご参考いただければ幸いです。

今回のブログを書くにあたり、参考にした文献・Webサイトはこちらになります。

おわりに

delyでは現在、 クラシル」「TRILL」を一緒に開発しながら共に成長していけるメンバーを絶賛大募集 しています。

join-us.dely.jp

もし、この記事を読んで「私も strong (monoidal) になりたい」と思いましたら、ぜひ私が先日書いた入社ブログも合わせて読んでみて下さい。 会社のカルチャーや中の人の雰囲気、事業内容について紹介しています。

note.com

また、2021/01/21 19:00〜にクラシルiOSチームのオンライン雑談会を開催します。 こちらもぜひ奮ってご参加ください!

bethesun.connpass.com

最後までお読みいただきありがとうございました!

delyのSREチームがオンコールトレーニングを導入する3つの理由

f:id:joe0000:20201223092159p:plain

こんにちは!

AWSのカオスエンジニアリングの新サービスもリリースされ、オンコールトレーニングへの関心が高まっているのを感じています。delyのSREチームのjoooee0000(高山)と申します。

この記事は「dely #2 Advent Calendar 2020 - Adventar」の24日目の記事です。

昨日は新規事業開発をしている おっくー (@okutaku0507) さんによる 「KPI自動通知Botで始める 数字に執着するプロダクトマネジメント|奥原拓也 / クラシルPdM|note」でした。 KPIの必要性から具体的なBot化の知識まで具体的に解説されているのでぜひ参考にしてみてください!

note.com

adventar.org

adventar.org

はじめに

今回は、delyのSREチームがオンコールトレーニングを導入する3つの理由を紹介したいと思います。

delyのSREチームはこれまで、元々在籍していたメンバーと、2019年の10月に入社したメンバー2人体制で運用してきました。そして今年(2020年)の10月から、新しくSREとして入社したメンバーと育休から帰ってきたメンバー(育休前はサーバーサイド担当)の2人を足して4人体制になりました。現在、4人になった新生SREチームをチームとして再スタートさせるべく、チームミッションの再定義や役割スコープの決定、チーム体制の整備を行なっています。

その中で、delyのSREチームはオンコールトレーニングの導入を決定しました。なぜ導入することになったかを3つの項目に分けて紹介していきたいと思います。

(なお、この記事のオンコールトレーニングがさす具体的なトレーニングの内容はSRE本の28章 「SREの成長を加速する方法: 新人からオンコール担当、そしてその先へ」で紹介されているトレーニング内容をベースとしたものです)

オンコールトレーニングを導入する3つの理由

1. オンコール体制強化が急務

まずはシンプルに現状の体制でのオンコール対応に限界があるためです。

現在は元々在籍していたシニアエンジニアがメインでオンコール対応をしており、他のメンバーは補助という形でオンコール対応にあたっています。

そのため、メイン担当者がオンコール対応において単一障害点となってしまっています。さらに、delyのSREチームが運用を担当しているサービスはレシピ動画サービスの「クラシル」に加え、最近GCPからAWSへのリプレースを行なった「TRILL」という女性向けメディアも追加され2つになりました。なので、特に障害発生タイミングが被った場合に地獄がおこります。同じAWSのサービスを使っているため、AWS障害時などには容易におこりうることが想像できます。

f:id:joe0000:20201223224036p:plain

「はい!その通りなのですが....」

delyのSREチームのメンバーは比較的新しいメンバーで構成されていることに加えて、サーバーサイドチームから異動したメンバーや、前職でがっつりオンコール対応をしていたわけではないメンバーも在籍しています。

そのような状況で、他のメンバーがオンコールのメイン対応を担えるようになるにあたり以下のようなハードルがありました。

オンコールのメイン担当者になるための3つのハードル

⑴ 新しいメンバーがオンコール対応をぶっつけ本番でやるのは危険

delyのSREチームが運用を担当している2つのサービスは、それぞれ月間利用者数千万人を超える巨大サービスです。 障害によりサービスが停止した際には大きな損害が出てしまうため、新しいメンバーがぶっつけ本番で対応するのは危険です。

⑵ シニアSREが優秀すぎて他のメンバーが実績を積む機会がない

この現象はオンコール対応にはよく発生する問題のようです。システム障害対応についての本にはこのようなオンコール対応の教育に関する問題について言及されています。

システム障害は、多くのシステムにおいて最優先の対応事項です。そのため、障害対応メンバーについては、社内で最もできる人間がアサインされます。あなたよりも経験が長いメンバーがいるのであれば、おそらく、あなたが障害対応を指揮する「インシデントコマンダー」になることはないでしょう。このような現場の場合、あなたはいつ経験を積むのでしょうか?それは、他にできる人間がいないときです。

(木村 誠明. システム障害対応の教科書.より)

これでは、メイン担当者の負荷が一向に減らず、永遠に単一障害点になり続けます。

⑶ 通常のプロジェクト業務だけではシステム全体像把握の効率が悪い/把握度が属人的になる

オンコール対応にはサービスが稼働しているシステムについての知識が必要になります。業務をこなしていく中でだんだんとシステムに関する深い知識が身についていくものだと思います。

しかし、アサインされたプロジェクトによってどこに知識がついていくか偏りが出てしまいます。 例えば、現在検証環境周りの再構築をやっているのですが、そこにがっつり関わっているメンバーは本番環境システム側に触れる機会が相対的に少なくなってしまいます。

また、自主的なリバースエンジニアリングや自主学習を通してシステムを理解することもできますが、自学のためどれほどの習熟度なのか判断する材料がないこと(どの程度の習熟度でオンコール対応できるという判断基準をもうけられない)と、効率が悪いことが問題としてあります。


この3つのハードルを越えるために、GoogleのSRE本を元にオンコールトレーニング導入を検討しました。

そこで疑問なのは、「そもそもオンコールトレーニングってどれくらいの効果があるの?どれくらいのチーム規模で導入するべきなの?」という話です。NetflixがオンコールトレーニングをやってAWSの障害に強かった話やGoogleのSREチームがオンコールトレーニングを導入している話などは有名ですが、あの規模だからできたことなのではと考えてしまいます。

しかし、Googleが出しているサイトリライアビリティーワークブックには、delyのチーム構成に似たオンコールトレーニングにおけるGoogleのモデルケースが存在していました。これは直接的な導入の理由になるわけではありませんが、似たチーム構成で成功例が存在していることは導入を後押しする理由になりました。

こちらはサイトリライアビリティワークブックのオンコール対応の章で紹介されていたモデルケースです。

マウンテンビューの Google SREチームの事例

<チーム構成>

● SREテックリードのSara

● 他のSREチームから来た経験豊富なSREのMike

● SREになるのは初めてのプロダクト開発チームからの異動者

● 4人の新規採用者(「Nooglers」)


<ストーリー>

数年前、マウンテンビューのGoogle SREのSara は、3 ヶ月以内にオンコールにならなければならないSRE チームを立ち上げました。

----中略----

チームが成熟していても、新しいサービスのオンコールになるのは挑戦であり、新しいマウンテンビューのSREチームは相対的に若いチームです。にもかかわらず、この新しいチームはサービスの品質やプロジェクトの速度を犠牲にすることなくサービスをオンボードできました。彼らは即座にサービスに改善を加えましたが、その中にはマシンのコストを40%下げたり、リリースのロールアウトをカナリアやその他の安全性チェックと合わせて完全に自動化したりといったことが含まれていました。新チームは 99.98%の可用性、言い換えれば四半期ごとにおよそ 26 分のダウンタイムをターゲットに、信頼性あるサービスを提供し続けてもいました。 新SREチームは、これほどのことをどのように自分たちで達成したのでしょうか?それは、スタータープロジェクト、メンタリング、トレーニングを通じてでした。

(サイトリライアビリティワークブック P149)

このチームは、オンコールトレーニングを実施することでたくさんのシステム変更を加えているにも関わらずSLOを達成し、サービスの信頼性を担保しています。そしてそれは、オンコールトレーニングによって成し遂げられたことということです。

また、この事例において具体的にどのようなトレーニングをおこなったかについても本に記載されています。

このような背景と最後の後押しが、オンコールトレーニングをdelyのSREチームに導入する意思決定につながりました。

2. SREの運用改善業務のアウトプット最大化

オンコールトレーニングは、シンプルにオンコール対応ができるようになることに加えて別の役割も果たすと考えています。それは、SREの通常業務をする上でも非常に重要な知識が習得できるという点です。

delyのSREチームの本来注力するべき業務はシステムの設計と運用の改善にあります。

改善には、下記のような工程が存在します。

①課題を発見する

②課題に適切な優先順位をつける

③適切な解決策を考えて実行する

ここで重要なことは、①と②の工程ができていなければ、どんなに③が優れていても影響は小さいということです。

よって、改善業務において①と②の工程がとても大事になってきます。つまり、チームのアウトプットを最大化するには、まず課題発見と優先順位づけの精度を高めていく必要があります。

そこで課題となってくるのが、delyのSREチームは比較的新しいメンバーが多いためまだシステムに対する知識にムラがあることです。特にシステムの全体を俯瞰して課題の優先順位づけを行うことにはまだハードルがあります。また、システムに内在する既知の課題を網羅的に把握する機会がありません。全員の課題発見のスピードを上げ、優先順位づけを行えるようになることで①と②の質を高めていく必要があります。

オンコールトレーニングを行うことにより課題発見と優先順位づけをする上で重要な知識が身につくと考えています。

オンコールトレーニングにはいくつかの種類ややり方が存在しています。それを大別すると、システムについての網羅的な学習障害をベースにした学習 に分類できます。

網羅的な学習

課題を発見するには、そもそもシステムについて知る必要があります。システムについて学習をすることにより、課題の発見/理解がしやすくなります。また、網羅的な学習により全体像が見えることで課題の優先順位づけの精度を上げることができます。

障害ベースの学習

障害ベースの学習を行うことにより、システムの現状の弱点を把握することができます。「システムの現状の弱点 = 課題」です。障害ベースの学習を行うことで、現状潜んでいる課題を知ることができます。

このように、オンコールトレーニングをすることが課題の発見と優先順位づけをスムーズにできる手助けをしてくれます。結果的に、オンコールトレーニングをすることでSREチームが出すアウトプットの価値を上げることにつながると考えています。

3. 新規社員がスムーズに戦力になれる仕組み作り

これまで、オンコールトレーニングを整備するとオンコール対応強化やSREの運用改善業務をする上でメリットがあるという話をしてきました。それにより、今後入社するメンバーにとってもスムーズに戦力になれる環境が整います。

さらに、新規社員を迎えるにあたりもう一つオンコールトレーニングを行うメリットがあります。それは、システムに関するドキュメントが豊富になるという点です。

delyにはもともと、在籍日数や立場に関係なく誰でも 活躍することができるうように情報の透明性を高くし、情報の非対称性をなくすという文化があります。

オンコールトレーニングで習得した知識をメンバーが ドキュメンテーションすることで現状のシステムの構成やモニタリング、ロギング、ロールバックの手順など豊富な知識が可視化されることになります。現状もドキュメントはかなり豊富なほうですが、オンコールトレーニングの導入によりさらに豊富になっていく予定です。これは、SREチームだけではなくサーバーサイドチームなど他のチームにとってもいい影響を及ぼすことが考えられます。

オンコールトレーニングを導入することで、入社後の戦力になるスピードを上げることができ、さらにドキュメントがそろうことでストレスなく業務にあたる環境作りが望めます。

まとめ

これらより、delyのSREチームではオンコールトレーニングを導入することになりました。オンコールトレーニングをすることはオンコール対応ができるようになるだけではなく様々なメリットがあります。

また、大規模なチームではなくても導入をすることができると考えています。 導入の仕方や実際になにをやっているかなどは今後のブログでまた共有していこと思います!

delyではSREメンバーを募集しています。@gomesuit @bababachi @akngo22 など愉快なメンバーが揃っていますのでご応募お待ちしております!

f:id:joe0000:20201223224305p:plain

join-us.dely.jp

また、「クラシル Tech Talk」などのイベントも多数行っています。 エントリー前に開発部の様子を知りたいという方はぜひ覗いてみてください。ご応募をお待ちしております!

bethesun.connpass.com

クラシルの新規事業を支える検索機能!Elasticsearchの導入と運用のポイント

こんにちは、delyコマース事業部サーバーサイドエンジニアの小川です。

最近クラシルにて、ネットスーパー機能のリリースができました! (以下 クラシルネットスーパー)

入社して1年くらいたちますが、とってもエキサイティングな毎日を過ごしています。

この記事は「dely #1 Advent Calendar 2020 - Adventar」の24日目の記事です。

前日は仁多見さんの記事でした!↓

思った以上に大変だったクラシルでの Scoped Storage 対応 - クラシル開発ブログ

はじめに

みなさんはElasticsearchを利用して、開発中のサービスに検索機能を導入したことはありますでしょうか。 今や様々なサービスで利用されているかと思います。

クラシルネットスーパーでは、キーワード検索以外の部分でもElasticsearchを活用しています。

レシピに紐づく食材を取得したり

f:id:te_o:20201222145500p:plain

商品を分類・識別したり

f:id:te_o:20201222145753p:plain

普通だったら、玉ねぎカテゴリに玉ねぎの商品を紐付ける運用をすると思いますが、 ネットスーパーの商品は1店舗当たり何万件も商品があり、それが日々入れ替わるので上記の運用が現実的ではありません。

商品の分類・識別の自動化が必須だったので、それを今回は自然言語ベースで行いました。 分類・識別や一般的なキーワード検索のどちらも行える、Elasticsearchがとても相性よかったです。

記事メディアとはまた違った検索を提供する特徴的な事例だと思いますが、 検索エンジンの最適化はコツコツとやっていくことがマストです。

最適化についてどんな機能があるか、また運用のポイントなどをまとめていこうと思います。

  • Elasticsearchについて基本だけは知っているけど使ったことない方々
  • 検索最適化したいけどやり方がわからない方々

の参考になれば幸いです!

最適化の際に必要なElasticsearchの知識

スコアリングの式を知る

Elasticsearchは特定のクエリに対して、類似度を測りスコアリングしています。 スコアリングは主に、tf-idf を利用して算出されています。

f:id:te_o:20201223111011p:plain

参考:https://lucene.apache.org/core/8_6_0/core/org/apache/lucene/search/similarities/TFIDFSimilarity.html

q がクエリ、d がドキュメント、 t がターム(ドキュメントの内容を形態素解析した得られた単語)として見てみましょう。

根拠を持って検索最適化を行うためには、スコアリングの式についての理解が必須です。

同義語について知る

Elasticsearchでは「同義語」をanalyzerに設定することができます。

analyzerの概念図 f:id:te_o:20201221183334p:plain

例えば、表記揺れやタイポなどは同義語として設定し、同じものとみなす独自のanalyzerを作成することができます。 例 (トマト,とまと,tomato)など

同義語はanalyzerの概念図のToken Filtersのレイヤーに属します。

以下の図は、同義語の概念について表しています。

内部のユースケースは、外部のユースケースより単純です。 外部に行くにつれ複雑になりますが、検索エンジンとしての洗練度・パワーが増します。

様々な「同一性」に対して同義語を使うことは不正解ではありませんが、 これらを理解せずに無作為に同義語の設定を行うと、予期せぬ挙動をする検索エンジンが爆誕します。

同義語はとても強力です。 うまく使いこなせば、様々なユースケースに対応できる検索エンジンができるでしょう。 しかしうまく使いこなせないと、気付いた時には運用の難易度がとても高い検索エンジンが誕生してしまうかもしれません。

参考:https://opensourceconnections.com/blog/2018/12/07/synonyms-by-any-other-name-part-1/

さらに精度を高めたい

他にもfunctionクエリやword2vecなどを組み合わせて最適化を行うことができます。 実は画像検索でさえできてしまいます。

詳しい説明は省きますが、そう言った手段もあることを把握しておくだけでとても有益です。 個人的にはword2vecについて興味を持っていて、より有益に使えないかなとソワソワしています。手段の目的化はヨクナイ

最適化の運用・設計のポイント

検索最適化の正解を明確にすること

これは意外と大切です。最初にざっくりでいいので、「想定されるクエリ」と「クエリに対する理想の検索結果」を整理しておきましょう。

例えばネットスーパーで言うと、

ユーザーは「トマト 2個入り」や「トマト 〇〇県産」とは検索しないと想定します。 おそらく「トマト」と検索して出た結果の中から、目当ての「トマト」を選択してカートに入れるでしょう。

となったとき、トマトに付随する「2個入り」や「〇〇県産」と言う単語は、無視できる事になります。 余計な単語は無視することが検索精度向上には大切です。

また、「トマト」と検索した時は、「トマトジュース」や「トマトケチャップ」が結果に出ることは望ましくありません。 「トマトジュース」が欲しいユーザーは「トマトジュース」と検索するからです。 何も最適化せずにElasticsearchを使うと、「トマト」で「トマトジュース」なども出てきてしまいそうですね。

以下のように検索最適化の正解を明確にすることが大切です。

  • 想定されるクエリ:トマト
  • クエリに対する理想の検索結果:原体のトマト(トマトジュースや、ケチャップはでない)

それにより、

  • 「2個入り」や「〇〇県産」と言う単語は、無視できる。
  • トマトジュースなどが出てこないようにするにはどういったらいいか

と言う事項を初めから考慮に入れられるようになります。

最適化の作業の難易度・影響範囲・作業量のバランス

検索最適化の正解が明確になって初めて、最適化の作業が始まります。

  • 想定されるクエリ:トマト
  • クエリに対する理想の検索結果:原体のトマト(トマトジュースや、ケチャップはでない)

を達成するためにたくさんのアプローチがあると思います。最適化の手法に正解はありません。

どのアプローチを選択するかの判断基準として、「難易度・影響範囲・作業量のバランス」を見ると良いです。

アプローチ①

f:id:te_o:20201222152346p:plain

プロダクトの初期においては必要なアプローチかと思います。

例えば、

  • 検索クエリが複数渡された場合、or検索だったのをand検索にする
  • タイトルとディスクリプションでスコア計算の重みが同じだったのをタイトル重めに調整する
  • ストップワードを追加する

などです。

初期はリソースが少ないかつ、影響範囲についてあまり考えなくても良い場面(リリース前など)があると思います。

100点の検索精度ではなく、70~80点くらいの検索精度を目指せれば良いかなと個人的に思います。

アプローチ②

f:id:te_o:20201222162211p:plain

アプローチ①の手法でだいたいの精度が出てきたら、移行して良いと思います。

1つ例をあげると、前述した同義語の概念のうち、Alt Labelsレベルの同義語の登録であれば、誰でもできるかと思います。 運用や設計次第ではもっと高レベルの最適化を専門知識なしに行えるかもしれません。

ここで大切なのは「いかに影響範囲を狭くできるか」になります。

チューニングするたびに現状の検索結果に影響してしまうと、 一方の精度は伸びるけど一方は落ちてしまうなんていうことになりかねません。

着実に70~80点から100点に近づけていける運用が組めると良いと思います。

結局は

プロダクトのフェーズ・特性・リソースなど様々な面から選択していくと良いと思います。

個人的には、「特定の分野における自然言語は限られている」かつ「長期的運用が必要なものが属人化するとヤバイ」と考えているので、 よほどの事がない限り、早い段階で作業内容がストックする前提で、アプローチ②が行えるような設計・運用を試みます。

最後に

以上Elasticsearchのポイントについてざっとまとめてみました。

クラシルネットスーパーでは、割と綺麗な運用で色々な要件を達成できました。 ネットースーパーの商品に手作業でラベルをつけなくても、分類・識別できるようになるのは大きいです。

技術に使われるのはなく、技術を使いこなすことが大切ですね。

レシピ連携機能を作る時、検索について何も知らない自分が弊社CTOに、 「レシピの食材とネットスーパーの商品を手作業で紐付けてくしかない」と言った時に、「ぼくたちがこれをトマトだと判別するのもアルゴリズムだから」と言われて一蹴されたのはとても昔に感じます。

明日はそのCTOである、たけさんの記事です。 お楽しみに!

delyではエンジニアの採用をひたすらやっていますので、興味ある方はぜひのぞいてみてください!

join-us.dely.jp

bethesun.connpass.com

思った以上に大変だったクラシルでの Scoped Storage 対応

f:id:rnitame:20201211113547p:plain

本記事は dely #1 Advent Calendar の 23 日目の記事です。

adventar.org

dely #2もあるのでこちらも見てみてください!

adventar.org

こんにちは、Android エンジニアの tummy です。

昨日はうめもりさんの「Androidも宣言的UI(が当たり前になりそうな)時代に非宣言的UIライブラリでこの先生きのこるには」でした。 絶賛このアーキテクチャで実装中ですが、以前より diff が少なくなったのとどこに何を書けばいいのかが明確になってとても捗ってます。ただ、まだ悩みポイントもあるのでこれから潰していきたいと思ってます 😎

さて、タイトルにある通りクラシルでも先日 Scoped Storage 対応をしました。

(今回は Scoped Storage についての説明は省きます、ドキュメントをぜひ参照してください 💁‍♀️)

developer.android.com

MediaStore 使ったりいい感じに権限つければいいんじゃないの?と軽く考えてたんですが、思ったより大変だったのでやったことを書いていこうと思います!

クラシルでの画像使用部分

クラシルでは以下の 2 箇所で画像を用います。

たべれぽ プロフィール
f:id:rnitame:20201211175341p:plain f:id:rnitame:20201211175402p:plain

カメラで撮影したものをそのまま使用できる他、ギャラリーから選択できるようになっています。 たべれぽのみ、フィルターをかけたりクロップすることもできます。

流れとしては以下の図のとおりになります。

f:id:rnitame:20201214173950p:plain

起きていた事象

切り抜きの画面でファイルが見つけられずに無限にローディング状態になっていました。(お問い合わせしていただいたユーザーさんのおかげです、ありがとうございます!)

またこの状態になると、謎のグレーな画像が端末内に保存されることがわかりました。

ローディングが終わらない 謎のグレーな画像たち
f:id:rnitame:20201211135054p:plain f:id:rnitame:20201211135409p:plain

当初は Intent の渡し方が悪かったのかな?と思ったのですが、よくよく調べていくと Scoped Storage に対応しないとだめだということがわかり、着手しました。

行った対応

カメラかギャラリーか判定見直し

ギャラリーを起動する際のコードを以下のようにしました。

val intentGallery = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
intentGallery.type = "image/jpeg"

こうするとギャラリーの Intent の情報に data がセットされるので、これがあるかないかで判定ができるようになります。 今までは変数として保持している Uri があるかないかで判断していたので、このロジックを変更しました。

FileProvider の活用

Uri.fromFile() を使うと共有の制限にひっかかるのと、権限をアプリ内で使うパスに渡すために FileProvider を使います。 qiita.com

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path
        name="external_files"
        path="." />
    <cache-path
        name="cache"
        path="." />
</paths>
private fun getUriFromFile(context: Context, file: File): Uri {
    return FileProvider.getUriForFile(
        context,
        BuildConfig.APPLICATION_ID + ".provider",
        file
    )
}

キャッシュを作ってそれの Uri を使い回す

Google Photos 経由で画像を選ぶと Uri が以下のような形式になり、Google Photos の ContentsProvider に対して権限をもらえないためエラーになります。

content://com.google.android.apps.photos.contentprovider/-1/1/content%3A%2F%2Fmedia%2Fexternal%2Fimages%2Fmedia%2F19018/ORIGINAL/NONE/~}

stackoverflow.com

そのためキャッシュを作り、その Uri を使い回すことで対応しました。

OkHttp の RequestBody に content スキームの Uri をのせるためのクラスを作る

普通のファイルであれば MultipartBuilder などを使って渡せましたが、content スキームの場合は渡せないのでひと手間加える必要がありました。

そのため以下のような RequestBody を拡張したクラスを作り、渡すようにしました。

class ImageRequestBody(
    private val context: Context,
    private val uri: Uri
) : RequestBody() {

    override fun contentType(): MediaType? = MediaType.parse("image/jpeg")

    override fun writeTo(sink: BufferedSink) {
        val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r") ?: return

        Okio.source(FileInputStream(parcelFileDescriptor.fileDescriptor)).use {
            sink.writeAll(it)
        }
        parcelFileDescriptor.close()
    }
}

初期対応で足りなかったところ

ドキュメントを開く Intent も投げる

画像の Intent のみだと、Galaxy などでデフォルトで入っているギャラリーアプリが認識されないことがわかりました。そのためドキュメントの Intent を使い、type を画像にしぼって開くことで認識してくれるようになったので、この対応を行いました。

 val intentDocument = Intent(Intent.ACTION_OPEN_DOCUMENT)
intentDocument.type = "image/*"
intentDocument.addCategory(Intent.CATEGORY_OPENABLE)

できてないこと

カメラで撮った際に画像を端末内に反映させていたのですが、その処理を消したのでできてません。冒頭に少し触れた、実装中のリアーキテクチャでできるといいな…と思っています。

さいごに

delyではエンジニアを絶賛募集中です。ご興味のある方はぜひこちらのリンクを覗いてみてください。 カルチャーについて説明しているスライドや、過去のブログもこちらから見ることができます。

join-us.dely.jp

delyの開発部では定期的にTechTalkというイベントを開催し、クラシル開発における技術や組織に関する内容を発信しています。
クラシルで使われている技術や、エンジニアがどのような働き方をしているのか少しでも気になった方はぜひお気軽にご参加ください!

bethesun.connpass.com

Androidも宣言的UI(が当たり前になりそうな)時代に非宣言的UIライブラリでこの先生きのこるには

f:id:delyumemori:20201221172934p:plain

こんにちは。dely株式会社でAndroidチームのマネージャーをやっているうめもり(Twitter: @kr9ly)です。

この記事は「dely #1 Advent Calendar 2020」の22日目の記事です。

21日目の記事は、kazkobay さんによる「デザイナーからPdMになる時に役に立った本と方法まとめ」でした。エンジニアがPdMになるときにも役に立ちそうな本なので、暇を見つけて読んでみようと思いました。

note.com

「dely #2 Advent Calendar 2020」もありますので、是非そちらもご覧ください。

Jetpack Compose前夜

皆さん、Jetpack Compose使ってますか?直近ですと12/2に1.0.0-alpha08、12/16に1.0.0-alpha09がリリースされ、開発が大分活発なのがうかがえますね。正式版リリースは来年末か再来年でしょうか?さすがにまだalpha版なので、業務で本格導入してる方はほとんどいないのではと思いますが、Reactから始まった宣言的UIの流れがとうとうAndroidにも来るのかという感じですね。

試しに触ってみたところすでになかなかの完成度で、React(+React Hooks)のような宣言的UIで普通にAndroidアプリを作る、という体験が2年後くらいには当たり前になっているような気がしました。

React(VirtualDOM)がUIプログラミングの世界にもたらしたもの

VirtualDOMを用いたReactというフレームワークがWebフロントエンド界隈にもたらしたものがなんだったか、ということはWebで調べればたくさん出てきますが、かいつまんで言えば以下の1点に集約されるでしょうか。

プログラマーをUIコンポーネントの状態の差分管理から解放した

UIコンポーネント(DOM)の状態の差分検知&更新処理をVirtualDOMという仕組みが一手に担うことで、プログラマーは常にアプリケーションの最新の状態を反映するVirtualDOMを構築するコードを書くだけで済むようになりました。その上、VirtualDOMが最小の差分を自動的に検出してUIを更新してくれるため、UIを更新するコストをあまり意識せずともパフォーマンスの高いコードが書くことができるようになった、ということはとても大きいでしょう。(もちろん差分検出処理自体の重さもアプリケーションが大きくなれば意識する必要が出てくることだとは思いますが、ここでは触れないでおきます)

もちろんWebフロントエンドではない世界なのでDOMもVirtualDOMもAndroidの世界には存在しませんが、Jetpack Composeでも同じような仕組みでUIコンポーネントの状態を差分更新することからプログラマーを解放してくれます。UIを組み立てる参照等価な関数を組み合わせるだけで、Jetpack ComposeはUIコンポーネントツリーの差分を検知してUIを更新してくれるので、プログラマーはアプリケーションの状態からUIコンポーネントツリーを組み立てることだけを意識してプログラミングすることができます。

プログラマーをUIの差分更新から解放したことで生じたもう一つ大きな変化は、アプリケーションの状態を一か所に集約しやすくなったことです。状態の差分をプログラマーが管理しなければならなかった時は、前の状態をどこかに持っておいて比較しながらUIを更新する必要があるため、どうしてもアプリケーションの状態がコードの中にバラバラに点在してしまいがちでした。

それを一か所に集約できるようになったため、状態をUIコンポーネントとして組み立てるコードと、集約された状態を更新するコードにアプリケーションコードを明確に分離することが出来るようになりました。集約された状態からUIコンポーネントに状態を反映する仕組みは、Reactでは "single source of truth" という言葉を用いて説明されています( https://ja.reactjs.org/docs/forms.html#controlled-components )。今ではAndroid開発者にもお馴染みだと思いますが、Flux(Redux, etc...)のような単方向データフローアーキテクチャは、この二つのコードが明確に分離しやすくなったからこそ生まれたアーキテクチャです。Fluxのような単方向データフローアーキテクチャーをAndroidアプリに適用しようとする試みは過去色々な場所で行われてきたことだと思いますが、VirtualDOMの仕組みのないAndroidの世界でFluxを実現しようとするのは、どうしても無駄なボイラープレートコードの増加や、暗黙的なアーキテクチャの制約などを生み出してしまいがちで、結果的に大きな生産性の改善には繋がりにくかったのではと思います。

UIコンポーネントの状態の差分管理からの解放と、UIコンポーネントの更新のコードとアプリケーションの状態の更新のコードの明確な分離は、確実にAndroidのプログラミングにおいてもコードの保守性を高め、UIプログラミングの生産性の向上に繋がるはずです。

……Jetpack Composeさえ導入できれば。

だがまだ本格的にJetpack Composeを導入するわけにはいかない我々がこの先生きのこる方法

Jetpack Composeはまだalpha版、下手すれば本格的に導入できるのは2年後でしょう。我々は2年後まで指をくわえて待っているのがよいのでしょうか?一応Androidの世界にもAndroid Data Bindingがあります(Android Data Bindingはプログラマーが状態の差分を意識しなければいけないのは何も変わらないですし、大きなコンパイル時間のペナルティがあるという問題もありますが…)し、今まで通りアプリケーションを作ることは問題なくできます。

しかし、Reactがもたらした

  • UIコンポーネントの状態の差分管理からの解放
  • UIコンポーネントの更新とアプリケーションのデータの更新の責務の明確な分離

については、部分的にその利点を取り入れながらAndroidアプリケーションの作り方をアップデートすることはできます。この記事では、その方法の要点だけ説明したいと思います。

非宣言的UIライブラリでsingle source of truthを実現する

差分更新と一括更新が同一のコードで行うことができるような仕組みを用意する

VirtualDOMのような差分検知&差分更新のライブラリがない世界では、何らかの方法で差分検知&差分更新の仕組みを実現し、UIコンポーネントの差分を管理する仕事からプログラマーを解放する必要があります。クラシルのAndroidアプリでは、以下のようなコードでUIコンポーネントの状態の更新を実現できるような仕組みを作りました。

override fun view(
    context: Context,
    state: RecipeItemState,
    updater: ViewUpdater<BaseBinding>
) {
    ...(省略)...
    updater.update(state.title, state.isPr) { layout, title, isPr ->
        val titleBuilder = SpannableStringBuilder()
        titleBuilder.append(title)
        if (isPr) {
            titleBuilder.setSpan(
                LeadingMarginSpan.Standard(
                    32.dpToPx(context), 0
                ), 0, title.length, 0
            )
        }
        layout.titleLabel.text = titleBuilder
    }

    updater.update(state.thumbnailUri) { layout, uri ->
        layout.image.bind(imageLoaderFactories.thumbnail(uri).build())
    }

    updater.update(
        state.isPremiumRecipe,
        state.isPremiumUnlocked
    ) { layout, isPremiumRecipe, isPremiumUnlocked ->
        layout.premiumLabel.visibility = isPremiumRecipe.visibleOrGone()
        layout.lockedLabel.visibility =
            (isPremiumRecipe && !isPremiumUnlocked).visibleOrGone()
    }

    updater.update(
        state.isFavorite,
        state.id,
        state.favoriteStateUpdatedTimestamp
    ) { layout, isFavorite, _, _ ->
        layout.favoriteButton.isActivated = isFavorite
    }

    updater.update(state.isPr) { layout, isPr ->
        layout.prLabel.root.visibility = isPr.visibleOrGone()
    }
}

updater.update関数にパラメータを渡して、その中でAndroidの通常のViewの操作を使ってUIを更新しています。

最初にこの関数を呼んだ時と、updater.updateに渡したパラメータのいずれかが更新されることでViewが更新される仕組みになっており、プログラマーが差分の粒度を意識する必要はあるのがVirtualDOMを使ったコードとのプログラミングとは違うところですが、最低限一括更新と差分更新の両方がこのコードだけで実現することはできています。

内部処理は結構単純で、以下のようなコードで実現されています。

class DiffDetector {

    private object EndOfState

    private var dirty = false

    private var index = 0

    private var diffBuffer = mutableListOf<Any?>()

    inline fun update(callback: () -> Unit) {
        startUpdate()
        callback()
        finishUpdate()
    }

    fun startUpdate() {
        dirty = false
        index = 0
    }

    fun detect(value: Any?): Boolean {
        if (dirty) {
            append(value)
            return true
        }
        if (index >= diffBuffer.size) {
            append(value)
            return true
        }
        if (diffBuffer[index] === EndOfState) {
            dirty = true
            append(value)
            return true
        }
        if (value == null || value::class.javaPrimitiveType != null) {
            if (value != diffBuffer[index]) {
                append(value)
                return true
            }
        } else {
            if (value !== diffBuffer[index]) {
                append(value)
                return true
            }
        }
        index++
        return false
    }

    fun finishUpdate() {
        append(EndOfState)
    }

    private fun append(value: Any?) {
        if (index < diffBuffer.size) {
            diffBuffer[index] = value
        } else {
            diffBuffer.add(value)
        }
        index++
    }
}

このクラスをラップしたのがViewUpdaterですが、内部的にはパラメータの配列を持っておいて、呼ばれた順にindexを動かしてパラメータを突き合わせているだけです。非プリミティブ型の場合は参照を突き合わせていますが、後述のStateをイミュータブルにすることで特に問題が発生しないようになっているのと、差分の検知処理がパフォーマンス上の問題にならないように気を付けています。

もちろんupdater.update関数をネストされるなどされると状態がおかしくなってしまうのですが、そこはネストした場合に例外を吐くなどの処理を追加することでおかしな呼ばれ方ができないようになっています。

Stateを集約し、イミュータブルにする

クラシルのAndroidアプリでは基本的にコンポーネント(Activity, Fragmentくらいの粒度で管理しています)内では単一のオブジェクトでStateを管理しています。Stateは原則イミュータブルで、State内のフィールドのいずれかが更新されるたびにStateのインスタンスが更新されるような仕組みになっています。

以下はクラシルのmodel関数(コンポーネントの状態を更新するロジックをmodelというfunctionにまとめています)内から一部抜き出したコードですが、基本的にStateはdata classとして定義しており、dispatcher.dispatch内でstateオブジェクトをcopyすることで新しいStateを作るという形で状態を更新しています。単一のオブジェクトであるStateが更新されるたびに、view関数が呼ばれて状態がUIコンポーネントに反映される仕組みになっています。

override fun model(
    action: Action,
    props: Props,
    state: State,
    dispatcher: StateDispatcher<State>
) {
    when (action) {
        is RecipeDetailListSnippet.Actions.UpdateRecipeSummary -> {
            dispatcher.dispatch {
                state.copy(
                    summary = action.summary
                )
            }
        }
        is RecipeDetailListSnippet.Actions.UpdateRecipeDetail -> {
            favoriteFeature.addBrowsingHistory(props.id)
            dispatcher.dispatch {
                state.copy(
                    detail = action.video
                )
            }
        }
        is RecipeDetailListSnippet.Actions.UpdateQuestions -> {
            dispatcher.dispatch {
                state.copy(
                    questions = action.questions
                )
            }
        }
        is RecipeDetailListSnippet.Actions.UpdateTaberepoInfo -> {
            dispatcher.dispatch {
                state.copy(
                    taberepos = action.taberepos,
                    taberepoCount = action.taberepoCount
                )
            }
        }
    }
}

コードの責務を分割し、Stateの更新によらないViewの更新を抹殺する

ここまでのコードで気づいた方もいらっしゃるかもしれませんが、クラシルのUIコンポーネント周りのコードは cycle.js のModel-View-Intentというアーキテクチャからの発想でコードを3つの関数に分割しています。

f:id:delyumemori:20201221171509p:plain

  • Android ViewからActionを発行するintent関数
  • ActionとStateをもとにアプリケーションの状態を更新するmodel関数
  • StateをもとにAndroid Viewを更新するview関数

Intentのコードの雰囲気だけ共有しておくと、こんな感じです。

override fun intent(
    layout: ComponentViewBinding,
    dispatcher: StatelessActionDispatcher<Argument>
) {
    layout.clearButton.setOnClickListener {
        dispatcher.dispatch {
            SearchInputSnippet.Actions.UpdateInputText("")
        }
    }

    layout.voiceButton.setOnClickListener {
        dispatcher.dispatch {
            SearchInputSnippet.Actions.StartVoiceInput
        }
    }

    layout.searchInput.addTextChangedListener { text ->
        if (layout.searchInput.hasFocus()) {
            dispatcher.dispatch {
                SearchInputSnippet.Actions.UpdateInputText(text.toString())
            }
        }
    }
}

責務を3つのコードに分割して、view関数だけでUIの状態を更新するようになっています。原理上はintentでViewを更新できてしまいますが、Actionが受け取れないこと、Stateが参照できないことで通常の用途では更新しにくいことで抑止になっていることと、あとはコードレビューでそういったコードが混入しないように気を付けています。

クラシルのAndroidアプリでは、single source of truthの考え方をこれらの工夫で実現しています。どうしてもAndroid Viewが元々そういった発想で作られていないこともあり、たまにワークアラウンド的なコードが必要になることもありますが(layout.searchInput.hasFocus()はそのワークアラウンドの一部だったりします)、基本的にはすっきりコードを書くことが出来ています。

コンポーネントツリー全体でもsingle source of truthを実現する

クラシルのAndroidアプリではFragmentの採用をやめたのですが、一方でコンポーネントツリーの分割統治はUIアプリケーションを作るうえでコードの再利用性などを考えても避けて通れない問題なので、独自の仕組みでコンポーネントツリーを分割統治できるようにしています。その詳細については今回は触れませんが、Fragmentなどを採用する場合でも考え方は一緒だと思いますので参考になればと思います。

ReactのパラメータがState/Propsに分かれている理由

クラシルのAndroidアプリを今のアーキテクチャにした際に、最初は全て単一のStateで作っていたのですが、途中からReactのように内部でプライベートで持つ状態をState、外部から渡される内部からは変更できない状態をPropsと分けて管理するようになりました。最初はなんらかのパラメータを外から渡してStateを更新する仕組みだったのですが、例えばディープリンクなどの処理によって親からコンポーネントの状態を変更することが発生すると、Stateと分離してPropsという状態を導入したほうが都合がいいことが分かったためです。

具体的には、親から渡されたパラメータをStateに反映し、かつ内部でそのパラメータを変更する場合に問題が発生しました。内部で親から渡されたパラメータを更新するようになると、上のコンポーネントはその変更を把握していないため、おかしなタイミングでStateが親から渡されるパラメータで上書きされてしまうという問題が発生しました。結局内部のパラメータを変更したいタイミングで上のコンポーネントに対してActionを投げるようにする、という形で対応したのですが、それは結局Propsという内部から変更不能なパラメータでUIの状態を更新しているのと同じことだということに後で気づき、その形に落ち着きました。

"一つ上のComponent"からStateを更新することを意識する

State/Propsの仕組みにアーキテクチャを移行する際に、コンポーネントツリー全体でsingle source of truthを実現する際に意識すべきことが一つあることがわかりました。それは、

親が知っているべきことは必ず親がStateを持ち更新すること

ということです。single source of truthに則ったアーキテクチャを採用すると、基本的には親から子供のコンポーネントに状態を問い合わせることがなくなるため、自然にそういった考え方になっていくのではないかとは思いますが。

限界はある、やりすぎないことも大事

今回クラシルのAndroidアプリのアーキテクチャについて全てのことをご紹介できたわけではないですが、以上のような工夫で宣言的UI時代も戦えるようなアーキテクチャにアップデートをはかっています。今後も生産性が高く、素早くユーザーに価値を提供できるアーキテクチャを維持していきたいですね。

とはいえ、基本的には非宣言的UIライブラリでやっていることなので、限界やちょっとした癖もあります。例えばRecyclerViewのスクロールのようなStateで管理すると困るような状態もあったり、アーキテクチャの統一感を意識しすぎると無理が出てくるような局面もあります。

95%位のユースケースは自然に書けるが、いざという時の5%くらいの抜け道は残しておくという発想で、開発に著しい無理が出ないような形を保っていきたいですね。

We're Hiring!

クラシルのAndroidチームでは、生産性の高いアーキテクチャを維持・アップデートしつつも楽しくプロダクト開発できる人、したい人を求めています。今回の記事を読んで面白そうだな、もっと話を聞いてみたいなと思った人、クラシルの開発に興味が出てきた人、是非お話ししましょう。クラシルの採用に興味なくても、記事が面白いと思ってくれた人とは是非お話してみたいのでTwitterなどで連絡いただけると嬉しいです。

f:id:delyumemori:20201221172144p:plain
きのこる先生

システム管理者に贈る「運用改善に役立った!」AWSの機能4選

こんにちは!SREチームの松嶋です。

こちらは「dely #2 Advent Calendar 2020」の20日目の記事です。 adventar.org

delyのアドベントカレンダーは#1もあるので、こちらもぜひ。 adventar.org

昨日は、maseoさんの「Google Optimizeでテストをしてる話」という記事でした。A/BテストでGoogle Optimizeを導入するか検討しているフロントエンジニアの皆さんはぜひ読んでみてください!

tech.trilltrill.jp

はじめに

私は昨年の11月にdelyへ入社しましたが、もう既に1年が過ぎてしまいました。体感的にまだ半年しか経っていない気持ちですが、そんな時間が過ぎるスピードの速さもdelyならではかもしれないと感じる今日この頃です。

delyのSREチームは、2020年9月までの約3年間、最大2人体制でなんとか頑張ってきました。10月以降は、新しくdelyにジョインしてくれたり、育休から復帰したメンバーが戻ってきたことによって、チームメンバーが4人に倍増しました。今までは足元課題を解決するために時間を取られていたのですが、現在は中々着手できていなかった課題にもアプローチができつつあるので、今後システムの改善速度が右肩上がりになる予感しかありません。なんだかとっても良い感じです。

少し前置きが長くなりましたが、現在delyでは、皆さんご存知の「クラシル」という動画レシピサービスの他にも「TRILL」という女性向けメディアの運営にも携わっています。最近、TRILLのバックエンドをGCPからAWSに移行したのもあり、delyが運営しているサービスの大部分はAWS上で動いています。

この記事では、クラシルやTRILLの実運用において、システム管理者の視点で「これは結構良いかも」と感じたAWSの機能を4つPickupして紹介しようと思います。

1. ECSサービス状態がCloudWatch Eventsで取得可能になった

システム管理者としていち早く知りたいと思うのは、サービスの異常状態ではないでしょうか。従来まではECSサービスの状態変化を取得するためには、ECS APIを経由する手段しかありませんでした。そのため、サービスイベントの状態を検知する仕組みを自前で実装し、運用する必要がありました。

昨年11月のリリースによって、ECSがCloudwatchEventsに向けてECS Service Action イベントを発行するようになったので、ECSサービスの状態変化がとても簡単に検知できるようになりました。

aws.amazon.com

このアップデートをうけてクラシルやTRILLでは、ECSサービスの異常を出来るだけ早く検知したかったので、「ERROR」と「WARN」イベントが発生したときはSlack通知するように設定しました。

例えば、実際どのような場合に助かったかというと、ECSのタスクがスケールアウトができない状態を早い段階で検知できたときです。この原因は、暫くデプロイされていないECSサービスのコンテナイメージがECRのライフサイクルポリシーによってイメージが削除されていたためでした。

f:id:akngo22:20201219014114p:plain
ECS サービスアクションの通知
この「SERVICE_TASK_START_IMPAIRED」というWARNイベントは、タスクが正常に立ち上がることができないことを意味しています。

タスクがスケールアウトできなくてユーザーに影響が出始めてから気づく・・・というような恐ろしい状態になる前に気づいて対応できたので、本当に良かったです。

2. System Managerオートメーション

続いて2つ目は、System Managerのオートメーションです。一昔前のクラシルのデプロイは、ジョブスケジューラーのRUNDECKを使ってEC2サーバーにSSHし、デプロイスクリプトを実行するというやり方でした。

しかし、RUNDECKサーバーは、本番環境と開発環境のどちらにも接続できる状態になっていたり、サーバー自体がコード管理されておらずブラックボックスになっていたりと課題を多く抱えていました。

何か代替手段がないかと検討したときに、候補としてあがったのが「System Managerのオートメーション」です。

docs.aws.amazon.com

オートメーションの良いところは、JSONドキュメントを書いてしまえば簡単にデプロイを自動化することができるところです。さらに、RUNDECKようにサーバーを自前で立てる必要もなし、SSHしないので鍵管理も不要なためセキュリティリスクも防げて、サーバーをメンテナンスするコストも削減できるので、今まで抱えていた課題をオートメーションによって解決することができました。

3. FireLens

続いて3つ目は、こちらも昨年11月に発表されたFireLensです。皆さんはコンテナログの収集はどのように管理していますか。小規模なアプリケーションであれば、コンテナから直接CloudwatchLogsにログを送るのが一番楽だと思いますが、クラシルやTRILLのような規模になってくるとログ収集だけで毎月のコストが無視できないほどになってきます。コスト面を考慮するなら、サイドカー方式で自前でFluentdコンテナを立ててログをElasticsearchやS3に転送する等が考えられますよね。

FireLensの何が良いかというと、サイドカー方式でFluentdやFluent Bitを利用するときのログ収集を楽にしてくれるところです。また、Fluent Bitを使うときはAWSが管理しているマネージドなDockerイメージを使うことができますし、タスク定義の中でログの出力先を指定すれば、FluentdやFluent Bitの設定ファイルをいじらなくてもログ転送が可能になります。

Filterプラグイン使ってログ加工したい場合は、カスタム設定ファイルが必要になりますが、それでも自前でコンテナ立てる場合よりシンプルになると思います。

aws.amazon.com

さらに、FirelensだとデフォルトでECS メタデータがフィードに追加されているので、どのクラスターのどのタスク定義から送られてきたログなのか識別することも容易です。このECS メタデータは、タスク定義内のオプションで有効/無効にすることが可能なので、FluentdやFluent Bitの設定ファイル側で何か設定する必要もありません。

f:id:akngo22:20201219111407p:plain
赤枠のフィードを出力するか否か「enable-ecs-log-metadata」オプションで指定できる

TRILLでは、FireLensを使いコンテナログをKinesis Data Firehoseを経由してElasticsearchに転送しています。TRILLは月間利用者数が4000万を超えており、日々大規模なトラフィックを捌いているのですが、現状ログ周りがボトルネックならずに運用できています。

prtimes.jp

4. AWS Chatbot

最後に紹介するのは、AWS Chatbotです。暫くの間ベータ版でしたが、今年の4月に一般提供が開始されました。弊社もChatOpsの仕組みを自分たちで作っているものもありますが、AWS Chatbotを使えばとても簡単にSlack連携ができるようになります。

aws.amazon.com

例えば、CloudwatchAlarmやCodeシリーズの通知(実行開始、成功、失敗)などを簡単にSlack通知することが可能となります。CloudwatchAlarmの場合は、アラートになっているメトリクスのグラフを添付してくれたり、CodeBuildが失敗した場合は、失敗したフェーズのエラーメッセージを通知してくれるので結構便利だなと感じました。

f:id:akngo22:20201219120732p:plain
CodeBuildの失敗通知例

しかし、通知内容のカスタマイズはできないので、自分たちが欲しい情報が取れない場合はChatbotは使えないですが、そんな手間かけずに知りたい情報に関してはChatbotで十分事足りるなと思います。

まだTerraformが対応していないのが1つの難点ですが、皆さんで"Thumb up👍🏻"してリリースされるのを後押しして待ちましょう。

github.com

まとめ

今回は、運用改善につながったと感じるAWSの機能をクラシルやTRILLの実例を交えて紹介しました。気になったものがあれば、ぜひ導入を検討してみてください。

明日は、TRILLの可愛くて素敵なデザイン周りを担当しているyuaoさんの「デザインの指示に迷った時は、 「要素に分解」がいいかもという話」です!お楽しみに!

最後に

また、dely ではエンジニアを絶賛募集中です! ご興味ある方はこちらのリンクから。

join-us.dely.jp

まず、dely開発部の雰囲気を知りたい方は、TechTalk というイベントを定期的に実施していますので、お気軽に参加いただけると嬉しいです。カジュアル面談もこちらから応募いただけます!

bethesun.connpass.com