Markdown と GitHub で社内規程を便利に管理

VP of Technology の星 (@kani_b) です。技術基盤や研究開発領域などを担当しつつ、社内の色々なことを技術の力でいい感じにする仕事をしています。セキュリティや AWS の話が好きです。

さて、みなさんは、ご自身が勤務する会社の就業規則を読んだことはあるでしょうか。 エンジニアに限らず、会社の全スタッフが仕事をする上で関わってくるのが、就業規則や情報セキュリティドキュメントなど、会社のルールや規程を記す文書です。 特にセキュリティやインフラに携わるエンジニアは、その改訂も含め携わったことがある方もいるのではと思います。

よくある文書管理

こうした文書は、以下のように管理されていることが多いようです。

  • ベースドキュメントは Word
    • 保存時は PDF で保存
  • 版管理は Word の編集履歴 + PDF に保存する際のファイル名
  • 編集は担当部門, 担当者のみが行う

かつてのクックパッドでも、上記のように作成された PDF ファイルを Google Drive に保存して従業員向けに公開していました。 この記事を書くにあたり他のいくつかの企業の状況を伺ったところ、細かな差異はあれど同じような運用をされている例がほとんどでした。

つらい点

上記のような管理において、自分がつらいと感じる点がいくつかありました。以下に挙げていきます。

レイアウト難しい問題

複数人で編集することを前提とした文書の体裁を Word や他のワープロソフトで保ち続けるのはなかなか難しいものです。 全員が習熟していれば良いのですが、習熟度に差があると同じレイアウトでさえ記述方法が違っていたりします。 「番号付きリストかと思ったら番号は手動入力されていた」「中央寄せかと思ったら全角スペースの数でレイアウト調整されていた」「改行の数が違うとレイアウトが崩れる」なんてことはよくある話ではないでしょうか。

そもそも、そこまで頑張って整えている体裁は本当に必要…?

版管理難しい問題

ワープロソフト側に版管理の機能が備わっていることも多いのですが、複数人での編集を前提とするとき、全員が意識して同様の管理を行う必要があります。また、担当者の引き継ぎによって文化が失われてしまうような悲しい事態も起こります。 それ以外にも、規程閲覧側に公開されるのは最終成果物である PDF ファイルのみであることが多く、差分を確認するためにはそのバイナリに対応したソフトを利用する必要があります。閲覧側にとっても便利とはいえない状況です。

これら2点が感じていた大きな問題ですが、他にも

  • 文書を横断した検索性が悪い
  • 複数人でのレビューが難しい

といった問題を感じていました。

社内文書管理に求めていたこと

ここまでに書いた問題を感じつつ、つらい〜と鳴きながら文書編集をしていたのですが、ある日雑談の中でそうした文書管理を担当していた、いわゆるバックオフィスの同僚も同じようなつらさを感じていたことを知りました。 そこで、規程や社内マニュアルなどの文書に求められることを簡単にまとめてみたところ、概ね以下のような条件を満たしていれば良いのでは、という結論に至りました。

  • 然るべき責任者の承認のもと編集されていること
  • きちんと版管理が行われていること
  • 編集すべきときにすばやく編集できること
  • 見たいとき、見るべきときにすばやく参照できること
    • 閲覧しやすいフォーマットであることが望ましい

あれ、これって…?

あえてプレーンテキストを使う

というわけで、Word と社内ファイルサーバおよび Google Drive で管理されていた社内文書を、Markdown で書かれたファイルを Git (GitHub) で管理する形に移行しました。

Markdown は、 GitHub 上でリッチな表示を使ったレビューが可能です。また Groupad と呼ばれる社内 Wiki では長年 Markdown 記法が使われていました。このため、Markdown という名前を知らなくても 「Groupad と同じ記法」と説明すれば通じる状況にあり、利用をはじめるにあたりあまり障壁はありませんでした。

f:id:kani_b:20190626175720p:plain
Markdown で書かれた就業規則

また、GitHub についても、数年前から全社員に GitHub Enterprise のアカウントが発行されており、人事部門や法務部門も Issue ベースでのやり取りに慣れているといったところから、ある程度スムーズに利用を開始することができました。

GitHub からのファイル編集

Git 移行にあたって最も障壁となりやすいのは、編集作業をどのような環境で行うか、という点だと思います。 素の Git コマンドをターミナルから使ってくれ、ではハードルはどこまでも高くなるだけですので、Git を使ったことがない同僚にはまず GitHub の編集機能を使ってもらうことにしました。 あまり利用されていないようにも思えますが、GitHub そのものにも編集機能が用意されており、ファイル編集や変更の Commit, Branch や Pull Request の作成なども可能になっています。Git の概念をすべて理解してもらうのでなく、使う機能を限定することで、極力移行をスムーズにしました。

改訂フロー

現在の文書改訂フローは以下のようなものです。

  • 担当者が改訂案を Markdown で起案し Pull Request を作成
    • 必要に応じて他の担当や上長からのレビューを受ける
  • 責任者は確認し、内容に問題がなければ承認とマージを行う*1
    • GitHub の Branch Protection を使い、責任者の Approve がなければマージできないようになっている
    • 会議体の承認が必要な文書は Pull Request のスクリーンショット (記録のため) ごと会議体にまわり承認されたのち、責任者によって Approve される
  • マージ後は自動的に公開される

GitHub を使った開発でも行われるようなフローで文書管理を行えるようになっています。

文書の公開

GitHub における Markdown ビューを使っても、文書として読むのに問題ないビューを得ることができます。 ですが、文書の移行をすすめるうち、複数ある社内文書のインデックス作成やカテゴリ分類、少し複雑な計算式表現などが必要になりました。*2

そこで、Markdown が利用可能な静的サイトジェネレータである Jekyll を使い、Markdown で作成した文書から静的ページを生成して社内に公開しています。ドキュメント用の Jekyll テーマである tomjoht/documentation-theme-jekyll を採用しました。 Jekyll は GitHub Pages によって GitHub 側でビルドを行えるので、作成した文書をそのまま提供することもでき便利です。

当初 GitHub Pages による提供を考えていたのですが、例えば入社が内定された (入社前の) 方に公開するなど、アクセス制御の要件が増えてきたため、現在では Jenkins 上で自動ビルドを行って Amazon S3 上でホストしています。

f:id:kani_b:20190626175943p:plain
Jekyll でビルドされ公開されている就業規則

クックパッドでの利用状況

現在クックパッドでは、就業規則や賃金規程などほぼすべての社内規程が上記のような形で管理されており、法務部門や人事部門も GitHub を使った文書管理を行っています。 従業員はビルドされた HTML ドキュメントを参照することも可能ですし、元の Markdown を確認することや、Git のコミットログを確認することも可能です。

情報セキュリティのガイドブックといった、改訂しやすい文書は担当者外からの Pull Request も受け付けています。レイアウトの修正のほかに、ルールそのものに対する提案も Pull Request として来るものが生まれており、議論しやすくなったと感じています。

また、文書全体のレイアウトを整えやすくなり、より構造を意識して書くようになったり、従業員からの検索性が上がったり*3といった効果もありました。

あくまで個人的な感想ですが、編集しやすくなったことで、改訂に対する (気持ち的な) 腰の重さも軽くなったように思います。規則やそれを記述する文書は従業員の業務を助けるものであり、会社や世間の状況が変わった際すばやく改訂できる状態を保つことはとても重要と考えています。

まとめ

クックパッドにおける社内規程の文書管理を Markdown および GitHub を使った管理に移行した事例についてご紹介しました。

担当者全員が Git を覚えなければならない世界にするのでなく、GitHub といったある程度親しみやすいインタフェースを間に利用するといったように、担当者や閲覧する従業員にとって良い形を追い求めていくことは非常に重要です。こうした改善は「やりたい人の独り善がりにならないようにすること」がとても大事な、かつ楽しい部分だと感じています。

また、クックパッドでは、今回ご紹介したように技術を活用しながら全社の業務をより良くしたいコーポレートエンジニアや、自らの領域を一緒に改善していける財務・人事といったコーポレート部門スタッフを大募集しております。興味をお持ちいただけましたら、キャリア採用情報 (https://info.cookpad.com/careers/jobs/) から詳細を是非ご確認ください。

*1:軽微な修正のため、担当者の判断でマージできる文書もあります

*2:たとえば、賃金規程において、賃金の計算式を示すのに MathJaxを使っています

*3:現在は GitHub における検索にまかせています

SwiftUIで使用されているSwift5.1の新機能

こんにちは。会員事業部の岡村 (@iceman5499) です。 普段はクックパッドアプリ(iOS)を開発しています。 先日San Joseで開催されたWorldwide Developers Conference 2019 (WWDC19)に参加し、そこでSwiftUIの発表をうけていくつか調べたことがあるので簡単にまとめておきたいと思います

SwiftUIの登場

今年のKeynoteの最後に、SwiftUIという新たなUIフレームワークが発表されました。 SwiftUIはReactやFlutterのような形式でViewを宣言して画面を構築できる、これまで使用されてきたUIKitとは全く異なる形式のフレームワークです (AppleのSwiftUI紹介ページ )

この発表をうけてKeynoteはとても盛り上がっていました。期間中もSwiftUIの話題でもちきりで、セッションも多く開かれていました

SwiftUIでできるようになること

  • DSLでViewを宣言的に適宜できるようになりUIの構成要素を簡単に表現できるようになった
  • コード編集中にリアルタイムにUIプレビューを利用できる *1
  • 余白調整やアクセシビリティ・ダークモード対応などがある程度自動で行われ、Human Interface Guidelinesに則った画面を作成しやすい
  • スムーズなアニメーションが簡単に設定できるようになった

UIKitではよくあるリスト形式の画面を作るだけでも TableViewDelegateTableViewDataSource のメソッドを多数実装したり、ラベルを上下に並べるのに

label.constraint(equalTo: otherLabel.topAnchor, constant: 16).isActive = true

などといった長いコードを書いていく必要がありましたが、SwiftUIではそれがすっきりして

 List(contents) { content in
    VStack {
      Text(content.title)
      Text(content.subtitle)
    }
 }

のような形でシュッと書けるようになりました 🎉

f:id:iceman5499:20190624145219j:plain
Introducing SwiftUI: Building Your First App

(Introducing SwiftUI: Building Your First App より)

実際にさわってみた感想

2019年6月現在。macOS Catalina 10.15 Beta 2 と Xcode 11 Beta2 を使用しています

プレビューめちゃくちゃ使いやすい!?

  • 起動して目的の画面にたどり着くための操作をしなくてもいい
  • その場でタップフィードバックなども試せる
  • モックデータを簡単に挿せる

あたりの機能は非常に便利で、今後のプロトタイピング開発やエラー表示のテスト、デザインドキュメントとしての利用など様々な場面での活用が予想されます

新規プロジェクトならいい感じに動いたのですが、一方で既存プロジェクトで動かそうとした場合にいくつかの問題点に遭遇しました

  • ビルドターゲットがiOS13未満に設定されているとプレビュービルドができない *2

Swiftでは @available を用いることで指定コードが有効化されるiOSバージョンを制御することができます。これを用いてビルドターゲットがiOS13未満のプロジェクトにおいては

@available(iOS 13.0, *)
struct ContentView : View {
    var body: some View {
        Text("Hello World")
    }
}

のように記述をすることでビルド及び実行することができるようになります(iOS13未満ではSwiftUIを使用できないため代わりの実装を用意する必要があります)

こちらの書き方を使用して既存プロジェクトからSwiftUIを利用する場合、Xcode11Beta2時点ではプレビュービルドはエラーとなり利用することができませんでした

  • Objective-C製ライブラリ(Firebaseなど)を使ってるとたびたびそれらのビルドが走る

これはSwiftUIの問題ではなくビルドシステムの都合だと思うのですが、プレビューを使用する際は変更部分のみがリビルドされるはずがObjective-Cを利用している場合にそれらのビルドが走ることがあり、現実的な待ち時間でプレビューを使用することが難しいことがありました

  • 新規プロジェクトでも急に止まったり調子悪くなったりしがち

こちらはシンプルにプレビューの描画が止まったり明らかにおかしくなったり、ビルドが長くなったりなどです

と、このような障害もあり、まだBetaであるため安定してないのはしょうがないですが、安定しないままリリースされる可能性も十分にあり得るため今のところプレビューはあくまで補助的なものと捉えています。

個人的にあるだろうと思った機能がないこともある

触っていくとだんだんと気づくのですが、Betaということもあって個人的に必要だと思った機能が実は存在していないといったケースがあります

  • HStack などを用いて複数のViewを等幅で配置できない *3
  • ボタンハイライト時の挙動を設定できない *4
  • 画面を閉じる or 戻るボタンを配置できない

コンポーネントはどんどん拡充されていくはずですので、リリース時点やその後のコンポーネントの拡充に期待です

SwiftUIで使用されているSwift5.1の新機能

SwiftUIにはSwift5.1で新規追加される機能がふんだんに使用されていました

  • @propertyDelegate
  • @_functionBuilder
  • Opaque Result Type
  • @_dynamicReplacement(for: )
  • KeyPathに対する @dynamicMemberLookup

順にみていきます

@propertyDelegate

Proposal: SE-0258

(※ Proposalでは Property Wrappers という命名になっていますが、Xcode11Beta2上ではまだ @propertyDelegate が使用されているため本記事ではこちらで表記します)

この修飾子をつけるとプロパティに対して新しいattributeを宣言できるようになります

例えば次のような Lazy を宣言してみます

@propertyDelegate enum Lazy<Value> {
  case uninitialized(() -> Value)
  case initialized(Value)

  init(initialValue: @autoclosure @escaping () -> Value) {
    self = .uninitialized(initialValue)
  }

  var value: Value {
    mutating get {
      switch self {
      case .uninitialized(let initializer):
        let value = initializer()
        self = .initialized(value)
        return value
      case .initialized(let value):
        return value
      }
    }
    set {
      self = .initialized(newValue)
    }
  }
}

このような @propertyDelegate が宣言されているとき、次のようにその宣言された型の名前でattributeを宣言できるようになります

@Lazy var foo = 1738

これは実際のコンパイル時に以下のように展開されます(イメージのための疑似コードです)

var _foo: Lazy<Int> = Lazy<Int>(initialValue: 1738)
var foo: Int {
  get { return _foo.value }
  set { _foo.value = newValue }
}

foo へのアクセスが _foo に移譲される形となり暗黙に Lazy<Int> の機能を利用できるようになります。Lazy では遅延初期化の実装がされているため、lazy var と同じような機能が @Lazy をつけることによって利用できるようになりました

Property Delegateを使用したSwiftUIの型は多数存在しています。 例えば @State ではViewに使用される値の更新検知をしており、View.body の中で @State つきの変数にアクセスするとその変数の監視が始まり、その値が変化したときに自動的にViewが更新されるといった挙動をみせています

また $ をつけることによってラップしている本来の型のオブジェクトにアクセスすることができます

$foo // → Lazy<Int>

このとき、さらにラップしている型に var delegateValue: T { get } が定義されていれば delegateValue を取り出すことになります

例えば @State では var delegateValue: Binding<Value> { get } が定義されている*5ため、

@State var inputText: String

...

var body: some View {
  // ↓ TextField.init(_ text: Binding<String>) に対して
  //   $inputText.delegateValue: Binding<String> を $inputText という記法で取り出して渡している
  TextField($inputText)
}

次のようなコードがある場合に $inputTextBinding<String> を返します。 TextField は自身への入力を Binding<String> を経由して別のところへ渡すというインターフェースをしています

ややこしいですが、これによってSwiftUIはViewへのデータバインディングのためのプロパティの更新検知を実現しています

@_functionBuilder

Forum、 Proposal: SE-XXXX

VStack {
  Text("Hoge")
  Text("Fuga")
}

このコードを見たときSwiftのエンジニアは当然 🤔となると思います。クロージャが返り値を持っておらず、途中で評価しただけの Text が何らかの形でクロージャの外に現れています

VStackのイニシャライザ(一部省略)はこうなっていて

init(@ViewBuilder content: () -> Content)

なるほど怪しい @ViewBuilder が生えてることがわかります

これは新たに追加された @_functionBuilder による機能で、どこかに @_functionBuilder struct ViewBuilder {} が宣言されているときクロージャ引数に @ViewBuilder を付与できるようになり、そのクロージャの中で評価された式は ViewBuilder が持つ各種build関数の中を通って出力されます

例えば中で2つのViewが評価されていたときはViewBuilderの public static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View 関数の中を通ります(1つ目に評価されたViewがc0、2つ目がc1として引数が与えられます)。結果、クロージャは TupleView という型のインスタンスを返します

Swiftにこのような言語機能が搭載されたことによって、Swiftの型検査を有効にしたままDSLが記述できるようになっています

Opaque Result Type

Proposal: SE-0244

これまで関数の返り値にprotocolを指定した場合はexistential type *6 にラップされて返され、ラップやアンラップの処理にオーバヘッドが発生していました。 またprotocolがassociated typeを持っていた場合はprotocolはGenericsの型パラメータを持てないので AnyHashable など型消去のテクニックを用いて返却する必要がありました。これは元の型の情報を失っているために本来比較可能でないもの同士を比較できてしまうなどのコードを記述できてしまいました

Swift5.1からその問題を解決するために、 "protocol P を満たすある一つの型" を返すという意味で some P という表現ができるようになりました。これによってPを満たす任意の型を実際の型を知らずとも扱えるようになりました

これがどのように作用しているかというと、 多くのSwiftUIの構造体は

struct Button<Label> where Label : View {

のようにGenericsでそのViewの内部にあるViewの型を指定して受け取ります。existentialはそれ自身のprotocolに適合しないのでexistential経由でGenericsへ型パラメータをわたすことはできず、この型パラメータのためにはconcreteな型を知る必要があります

ただしSwiftUIの型は非常に複雑で、例えば上の

VStack {
  Text("Hoge")
  Text("Fuga")
}

VStack<TupleView<(Text, Text)>> 型です。 これはまだマシですが、

List {
    Section {
        ForEach(names.identified(by: \.self)) { name in
            Text(name)
        }
    }
}

だと List<Never, Section<EmptyView, ForEach<IdentifierValuePairs<Array<String>, String>, Text>, EmptyView>> 型になります。 こんな複雑な型をいちいち返り値に記述することは人間には難しいですし、変更に弱すぎます

そこで、それらをまるごとひっくるめて some View として表現できるようになっています。 このような表現をSwift proposalでは、Opaque Result Typeと説明しています

var body: some View {
    List {
        Section {
            ForEach(names.identified(by: \.self)) { name in
                Text(name)
            }
        }
    }
}

実際のコードはこうなので、上のような複雑な型を書く必要がなくなっています。 この機能によって実装中に複雑な型の存在を意識せずともViewを取り扱えるようになっています

@_dynamicReplacement(for: )

Forum

これはXcodeでのプレビュー用に使われている属性で、dynamic 修飾子がついた関数などにこの属性がついたモジュールをロードしてあげるとその関数の実装を入れ替えることができるようになります。 SwiftUIのPreviewではこれを用いて実行中のシミュレータが持つバイナリの実装を動的に差し替えてリアルタイムなプレビューを実現しています

ちなみにこの挙動の存在は、Preview機能がクラッシュしたときのエラーログからXcodeがプレビュー対象のコードに @_dynamicReplacement(for: ) をつけて回っていてあとから差し替えてる様子が確認できたことから確認しました

さながらObjective-C時代のMethod Swizzlingですね

KeyPathに対する @dynamicMemberLookup

Proposal: SE-0252

@dynamicMemberLookup は以前からSwiftに実装されている機能ですが、今回新たにKeyPathに対してsubscriptできるようになりました。 具体的な定義はこんな感じです

// BindingConvertibleの例
subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Self.Value, Subject>) -> Binding<Subject> { get }

任意のKeyPathをdynamicMemberLookupできるようになったため、プロパティアクセスのふりをしつつ型安全にsubscriptでアクセスできるようになりました。 これが具体的にどういうことか、以下のコードをみてみましょう

@dynamicMemberLookup struct Box<T> {
    var value: T

    subscript<U>(dynamicMember keyPath: WritableKeyPath<T, U>) -> U {
        return value[keyPath: keyPath]
    }
}

struct User {
    var name: String = "taro"
    var age: Int = 42
}

let boxedUser = Box(value: User())
print(boxedUser.age) // → 42

boxedUser.age はいかにも Box に生えているように見えますが、実際にアクセスする先は User の持つ age となっています。 このようにして、 @dynamicMemberLookup を使用することで subscript で指定されてる型に適合するkeyPathを \.age などの記法を使わずに取り出してあたかもプロパティ呼び出しであるかのようにsubscriptに流し込んで呼び出せるようになっています

これはSwiftUIではprotocolの BindableObject で活用されており、

struct ViewModel: BindableObject {
  var name: String
  ...
}

@ObjectBinding var viewModel: ViewModel

として宣言されている viewModelに対して、

TextField($viewModel.name)

のように $viewModel.nameBinding<String> として取り出す操作を可能にしています。 (ObjectBindingのdelegateValueは ObjectBinding<BindableObjectType>.Wrapper 型であり、それはKeyPathのdynamicMemberLookupで Binding<T> を返す) 一見viewModelに生えてるStringのプロパティを取り出しているように見せかけてBindingを返せているのでpropertyDelegateの恩恵をぶら下がってるプロパティにも適用できるようになっています

まとめ

SwiftUIで使用されているSwift5.1で追加された新機能について調べてみました。 マイナーアップデートでありながら大胆な機能が多数追加されてコードの様子が一気に様変わりしましたね。見た目は大きく変わりつつも中身は型の効いてるSwiftらしさがあり挙動や実装を調査していくのはとても楽しいですね

クックパッドアプリ(iOS)は1年前のiOSバージョンまでサポートする運用をしており、なんとあと1年とちょっと待てばSwiftUIが実用段階になる予定です。 また新規アプリを作成する際は最初からSwiftUIでやっていけるかもしれません。 クックパッドではSwiftUIを使ってすばやくサービス開発していくエンジニアや、SwiftやXcodeに詳しく開発環境を改善していけるエンジニアを募集しています

https://info.cookpad.com/careers/

*1:正確にはXcode11&macOS Catalinaの機能

*2:Beta2時点

*3: 内部を HStack { Spacer(); content(); Spacer() } で囲む、GeometryReader でframe直打ちなどのやり方はありますがすっきりするものではありません

*4:longPressAction や DragGesture を使うという裏技もありますがすっきりするものではありません

*5:https://developer.apple.com/documentation/swiftui/state/3287851-delegatevalue

*6:https://blog.waft.me/2017/10/27/swift-type-system-08/ などで詳しく解説されています

モダンBFFを活用した既存APIサーバーの再構築

技術部の青木峰郎です。 去年までは主にデータ分析システムの構築を担当していましたが、 最近はなぜかレシピサービスのサービス開発をやっています。 今日は、そのサービス開発をする過程で導入したBFF(Backends for Frontends)であるOrchaについて、 導入の動機と実装の詳細をお話しします。

Orcha導入にいたる経緯

まずはOrcha導入までの経緯、動機からお話ししましょう。

最初のきっかけは、わたしが去年から参加しているブックマークのようなサービスの開発プロジェクトでした。 このプロジェクトの実装のために新しいmicroserviceを追加することになったのですが、 そのときにいくつかの要望(制約)がありました。

1つめは、撤退するとなったときに、すぐに、きれいに撤退できること。

2つめが、スマホアプリからのAPI呼び出し回数はできるだけ増やしたくない、という要望です。

図1を見てください。 既存APIサーバーとは別に新しいmicroservice(API)を追加してスマホアプリから呼べば、 今回追加する部分はきれいに分かれていて実装も簡単です。 しかし、それではスマホアプリからのAPI呼び出し回数が増えてしまいます。

f:id:mineroaoki:20190621215345j:plain
図1: 単純にサービスを増やすとAPI呼び出し回数が増えてしまう

例えばクックパッドアプリのトップページは現在でもすでに10以上のAPIを呼んでいるので、 もうできるかぎりAPI呼び出し回数を増やしたくありません。

かと言って、既存APIサーバー(Pantry)の改修もしたくありません。 図2のように、Pantryから新サービスを叩くように変更すればAPI呼び出しを1つにまとめることはできます。 しかしこのPantryというサーバーは以前の記事で説明した「世界最大のモノリシックなRailsアプリケーション」であり、 理由はよくわからないがとにかくこれをさわるだけで開発期間が3倍になる優れモノです。 できることならいっさいPantryにさわることなく開発を終えたいわけです。

f:id:mineroaoki:20190621215427j:plain
図2: Pantryをいじれば目的は達成できるが対価が必要

つまり、API呼び出し回数は増やしたくないのでできれば既存のAPIに値を追加する形で実装したい。 しかしそのためにPantryはいじりたくない。

API呼び出し回数を増やしたくない……既存のAPIに手を加えたい……でもPantryはいじりたくない……。

この3つの思いが謎の悪魔合体を遂げて生まれたのがOrcha(オルカ)なのです。

Orcha 〜クックパッドのためのBFF〜

Orchaを導入した後のアーキテクチャを図3に示しました。 見てのようにOrchaはリバースプロキシと既存のAPIサーバーであるPantryの間にはさまって、 スマホアプリに特化したAPIを提供します。

f:id:mineroaoki:20190621215234j:plain
図3: Orchaのアーキテクチャ

今回は既存APIに新規サービスの情報を追加したいというのがそもそもの目的だったので、 まずOrchaがPantryのAPIを呼んで、レスポンスで得たJSONに新規サービスからの情報を差し込むことで目的を達成しています。 この場合のOrchaは「高機能なJSON用sed」のような働きをします。

OrchaはクックパッドのiOS/Androidアプリに特化したAPIを提供することを主眼としたシステムなので、 いわゆるBFF(Backends for Frontends)だとも言えます。 BFFとは、スマホアプリやウェブフロントエンドのような特定のクライアントに特化したAPIサーバーのことです。 汎用のAPIではなく、あるクライアントに密着した固有のAPIを提供することを目的にしています。 BFFについての詳細はこのあたりの記事をお読みください。

ちなみに、当初はBFFというよりオーケストレーション層を作るぞ! という気持ちのほうが強かったので、 "Orchestration Layer" の先頭を適当に切ってOrchaと命名しました。

すべてのAPIはカバーしない

Orchaはこのような経緯で導入したため、現在のところ、 スマホアプリが必要とするすべてのAPIを提供しているわけではありません。 スマホアプリのトップページのすべてのデータを返すトップページAPIなどの、スマホアプリに特化したAPIの一部のみを提供しています。 残りのAPIについては、現在もリバースプロキシからPantryへ直接リクエストを投げています。

そのような中途半端な入れかたをした1つめの理由は、 もし今回のプロジェクトがうまくいかなかったときは新規サービスをOrchaごと捨てて撤退する予定だったからです。 まるごと捨てるなら、必要最小限のAPIだけをOrchaにサーブさせておいたほうが、当然捨てるのも簡単です。

2つめは消極的な理由で、Orcha経由にするメリットが特にないからです。 既存のAPIをOrcha経由で呼ぶようにしたところで、単にレイテンシが数ミリ秒増えるだけで、たいしていいことがありません。 強いて言うとこの記事を書くときに「一部しか経由しませんよ」という説明をしなくて済むくらいでしょう。 それにもし将来メリットが発生してOrchaを経由するように変えようと思ったら、その時に変えればいいだけです。 したがって、当初は実装量がより少ないほうを選ぶことにしました。

Orchaの実装設計

Orchaの実装言語はしばし悩んだのちJavaに決めました。 Spring WebFluxとSpring Reactorを使って、非同期のリクエスト処理を実装しています。

JavaとSpringを選択した第一の理由はパフォーマンスです。 Pantryはやたらとリソース食いなので、 1 ECS task(サーバー台数とおおむね同じ意味)が3 CPUコア、メモリ4GBで、 毎日のピークタイムには150以上のECS taskが必要になっています。 これと同じ調子でリソースをバカ食いするサーバーをもう1つ立てるのはさすがに避けたいところです。

またレイテンシについても気を遣う必要があります。OrchaをPantryの前に立てるということは、 Orchaでかかったレイテンシーがそのまま既存のレイテンシーに追加されるということです。 Orchaのレイテンシーはできるだけ小さくしておかなければ、 スマホアプリの使い勝手を大きく悪化させてしまうことになるでしょう。 それを避けるには例えば、複数システムへのAPI呼び出しを並列化するなどの工夫をすべきです。

さらに、どのようなAPIを呼ぶことになるかは予測できないので、非常に遅いAPIもあるかもしれません。 そのような場合にもワーカーを使い果たして停止するようなことのないアーキテクチャを選択する必要があります。 ここまで来ると選択肢は非同期I/Oしかないでしょう。

非同期リクエストのフレームワークがあり、実行効率が高いとなると、定番はJVM系かGoです。 そこで結局、何度もJavaを利用した実績があったこと、 Java 8とJava 9での改善およびLombokの登場により言語仕様に目立った不満がなくなったこと、 さらに品質の高いAWS SDKやDBドライバがあること、の3点からJavaとSpringに落ち着きました。

なお正確に言うと、限定公開を始めた当初はリクエスト数も非常に少なかったため、 Spring WebMVCを使って同期リクエスト処理を実装しました。その後、全体公開することが決まった時点で、 API単位でSpring WebFluxに切り替えて非同期化していきました。 ここは同期・非同期のフレームワークが両方あり、しかも同居が可能なSpring Frameworkの利点が最大限に活きたところです。

認証処理の共通化

パフォーマンス向上という点ではOrcha導入にあたってもう1つ配慮したポイントがあります。 それは認証処理の共通化です。

図4はOrcha導入前のクックパッドアプリの認証経路です。 一言で言うとAuthCenterというシステムがすべての認証を請け負っており、 マイクロサービス各位はそれぞれ独立に認証を行うという仕組みです。

f:id:mineroaoki:20190621215506j:plain
図4: これまでの認証処理

これまではそれでも大きな問題はありませんでした。 なぜなら、基本的に1 APIは1システムによってハンドルされていたため、 APIリクエスト数と認証回数が等しかったからです。

しかしOrcha導入後は、1 APIリクエストにつき2回以上の認証処理が発生します。 つまり、最悪の場合はAuthCenterへのリクエスト数が突如として2倍以上になる可能性があるわけです(図5)。

f:id:mineroaoki:20190621215527j:plain
図5: 何も考えずにOrchaを導入したときの認証処理

AuthCenterは現時点でもすでに社内随一のリクエスト数を誇る人気サービスで、 ぶっちゃけた話DBがけっこうパツパツだったりするので、 いきなりリクエスト数が倍になれば陥落する可能性もあります。 それはいくらなんでもまずかろうということで、 Orchaの全体公開に合わせてID tokenを使った認証処理の共通化を実装しました(図6)。

f:id:mineroaoki:20190621215614j:plain
図6: ID tokenを使った認証の共通化

仕組みはこうです。 まず最初にリクエストを受けたOrchaはAuthCenterにアクセストークンを渡して検証してもらい、 認可などのためのメタデータを含むID tokenを受け取ります。 Orchaはアクセストークンの代わりに、AuthCenterから受け取ったID tokenを各サーバーに付与してリクエストします。

ID tokenは、JWTという形式のJSONを秘密鍵で署名したものです。 秘密鍵に対応する公開鍵は社内の全サービスに共有されているため、 そのトークンは間違いなくAuthCenterが発行したものであることが検証できます。 つまり各サービス内でその検証だけ行えば、 いちいちAuthCenterに問い合わせなくとも認証を完了することができるのです。

このへんはめんどくさかったのでわたしにはあまり知見がなかったので、 弊社の無敵万能エンジニア id:koba789 に仕様決めから全部ぶんなげて実装してもらいました。 そのうち id:koba789 が詳しいことを書いてくれると思います。

サービスメッシュを使った他システムとの連携

Orchaと他の上流システムとの通信は、 すべてクックパッドの標準的なサービスメッシュシステムを介して行いました。 サービスメッシュは特にBFFだから使うというものではありませんが、 個人的に今回いくつか利点を実感できたので述べたいと思います。

まずサービスメッシュでの自動リトライ機能について。 エンドポイントごとにタイムアウトを設定でき、タイムアウトした場合は自動的にリトライする、 それでもだめならしばらく通信を止める(サーキットブレーカー)という機能があり、これが非常に便利です。 最初はリトライなんていつ起きるんだよと疑っていたのですが、実際に試してみたら毎分起きていて認識を改めました。 また障害などで大量にエラーが発生したときにはサーキットブレーカーが働いて輻輳を防止してくれるので、 高いレベルで可用性を高めてくれます。

第二にクライアントサイドロードバランシングが容易に実装できる点。 Orchaには上流システムにgRPCのシステムがいくつかあるのですが、 普通のHTTP通信でも、クライアントサイドロードバランシングのgRPCでも、 こちら側の設定はほぼ同じ設定で通信できるようになるのでとても楽でした。

最後に、他システムとの通信のメトリクスが自動的に取得されて視覚化される点です(図7)。 これは正確に言えばサービスメッシュ自体の機能ではなく「サービスメッシュがあると容易に実装できる機能の1つ」です。 自システムで発生したエラーの数はもちろん、どの上流システムとの通信で500がいくつ出ているのか、 どのシステムとの通信が遅くなっているかも一目でわかるため、性能調査や障害調査に役立ちまくりでした。 他社のエンジニアにこの画面を見せると異常にうらやましがられる画面です。 この画面のためだけにでもサービスメッシュを実装する価値があると思います。

f:id:mineroaoki:20190621215640p:plain
図7: 他システムとの通信のモニター画面

Orcha導入後の評価

以上が、Orchaを入れた経緯とその設計などの詳細です。 これを踏まえて、現時点までの結果と評価を述べます。

まず、当初の目的であった 「API呼び出し回数を増やさずに、撤退しやすい仕組みで、新規サービスを高速に追加すること」は問題なく達成できたと思います。 Orchaと新規サービスを合わせて、インフラ構築からとりあえず動き出すまでをわたし1人だけで、1週間で完了できました。 これはPantryで開発をしていてはとても達成できない目標でした。 また、現在は新卒で入ったばかりのエンジニアにOrchaの開発をしてもらっているのですが、こちらもスムーズに開発できています。 これもPantryではありえないことです。

第二に、かなり真剣に考えたパフォーマンスについても、全体公開後の数値を見るかぎり問題なさそうです。 現在、ピークタイムでも全プロセスの合計リソースがCPU 1コア、メモリ8GBで余裕をもって全リクエストをさばけています。 もちろんECS(Docker)で動いていますし、オートスケールを設定してあるので、必要なときは勝手にECS task数が増減されます。 非同期処理に特有のつらい点として、「ものすごい勢いでメモリリークする」などの問題が全体公開直後に発生したりしましたが、 これも早期に解決できました(タイムアウト設定の問題でした)。

第三にJavaとSpringの選択についても満足しています。 Springについてはいろいろいい点はありましたが、 まずデフォルトでアプリケーション設定がファイル(application.yml)と環境変数で透過的に設定できる点が便利です。 開発環境ではいろいろと便利なデフォルトや設定例を提供したいのでapplication.ymlをレポジトリにコミットしておき、 本番環境ではDocker前提なのですべての設定を環境変数で設定する、ということが簡単にできるので大変便利でした。 また当然ながら設定項目はアノテーション一発でオブジェクトに自動マッピングしたうえDIで注入できます。

ちょっとした追加の機能実装をしたいときにほぼ間違いなくライブラリがある点も有利です。 例えば開発環境でだけ動く単純なリバースプロキシ機能を追加したくなったのですが、 Spring Cloud Gatewayを導入し、application.ymlを少し書くだけで簡単に実装できました。 このへんのライブラリの充実っぷりはさすがです。

総じて、アーキテクチャ・実装設計ともに現時点では満足しています。 次のチェックポイントは、スマホアプリのエンジニアがさわるようになったときでしょう。

これからのOrcha開発ロードマップ

最後に、今後のOrchaの開発ロードマップについて今考えていることを述べます。

直近の目標は、Orchaをより完全な集約層にすることです。 具体的には、既存のAPIサーバー(Pantry)に存在する、 実質的に集約層として機能しているAPIのコードをすべて剥がしてOrchaに移動することです。

集約層的なAPIは全体からすれば数は少ないですが、実装が複雑なので分量はけっこうあります。 このコード移動を完遂して、スマホアプリの開発者がOrchaをいじれるようになることが当面のゴールです。

また、集約層的なAPIの移動が完了すれば、 残るAPIはすべてリソースを処理するAPIになるはずなので、 そちらは小さいシステムに分割してgRPCにしてしまいたいところですが…… これが終わるにはあと何年かかるやら、という感じです。終わりが見えない。

まとめ

本稿では、クックパッドのレシピサービスに新たに追加したBFF "Orcha" に関して、 その動機と実装、評価をお話ししました。 今回、個人的に一番うまくやれたと思う点は、既存システムの改善と新機能の追加を両立できたことです。 通常、この2つは利益相反の関係にあることが多く、どちらを取るかジレンマに悩まされがちです。 しかしOrchaについては珍しいことに両者を同時に満たす一石二鳥の手を打てたので大変満足しています。

では最後にいつものやつです。

弊社は世界最大のモノリスを共に崩していく仲間を募集中です。 三度のメシよりRailsが大好きなかたも、 RailsアプリをJavaに書き換えてこの世から消滅させたいかたも、 あとついでに今回の話とは関係ないですがデータエンジニアも S3とSQSとLambdaでAWSピタゴラスイッチしたい人も、 ともに大募集しております。 興味を持たれたかたはぜひ以下のサイトよりご応募ください。