iOSアプリの大規模なCustom URL Schemeを支える技術

こんにちは。技術部モバイル基盤グループの@です。

今回は、iOSアプリでCustom URL Schemeを簡単に処理するライブラリを公開しましたので紹介します。

Custom URL Schemeは、アプリの特定の画面に遷移させることができるリンク(ディープリンク)を提供する機能です。

f:id:gigi-net:20180530205333g:plain

アプリ開発をしていると、Custom URL Schemeを用いたディープリンクを実装したい需要は多いでしょう。 特にクックパッドのような、ブラウザ版を提供するWebサービスですと、アプリとWebページの行き来のため非常に多くのCustom URL Schemeを処理する必要が出てきます。

現に、クックパッドアプリでは、30以上のパターンが遷移先として実装されています。

渡ってきたURLのパーサーを愚直に書いていくのは、コードの記述量も増えますし、どのようなURL Schemeが有効なのか簡単に見通すことは難しいです。

Crossroad

そこで、複雑なCustom URL Schemeのルーティングを簡単に実現するライブラリをOSSとして公開しました。

例えば、あなたがiOS上で「ポケモンずかん」を実装する仕事を請け負ったとしましょう。

Crossroadを用いると、以下のような記述でCustom URL Schemeのルーティングが行えます。

let router = DefaultRouter(scheme: "pokedex")
router.register([
    ("pokedex://pokemons", { context in 
        let type: Type? = context.parameter(for: "type")
        presentPokedexListViewController(for: type)
        return true 
    }),
    ("pokedex://pokemons/:pokedexID", { context in 
        guard let pokedexID: Int = try? context.arguments(for: "pokedexID") else {
            return false
        }

        guard let pokemon = Pokedex.find(by: pokedexID) else {
            return false
        }

        presentPokemonDetailViewController(of: pokemon)
        return true
    }),
])
router.openIfPossible(url)

このように、Ruby on Railsのroutes.rbのようなルーティングを記述することができます。

この仕組みをクックパッドアプリでは、1年以上前から運用していたのですが、今回、別のアプリでも使いやすい形で提供するためにOSS化しました。

同様のライブラリはいくつか公開されていますが、Crossroadはこれらに比べ、パラメータをType-Safeに、そして簡単に取り扱うことができます。

使い方

Crossroadの基本的な使い方を見ていきましょう。

URLのルーティング

iOSでは、Custom URL Schemeからアプリが起動されると、UIApplicationDelegateapplication(_:open:options:) が呼び出されます。

基本的な使い方は、AppDelegateで、ルーティングを定義した Router を生成し、そこでopenIfPossibleを呼び出すだけです。

import Crossroad

class AppDelegate: UIApplicationDelegate {
    let router: DefaultRouter = {
        let router = DefaultRouter(scheme: "scheme")
        router.register([
            ("scheme://search", { _ in return true }),
            // ...
        ])
        return router
    }()

    func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey: Any]) -> Bool {
        return router.openIfPossible(url, options: options)
    }
}

:から始まるパスは任意の文字列にマッチし、あとでマッチした値を参照できます。 URLパターンは定義順に上からマッチするかどうかが判定され、ブロックから true を返された時点で探索を終了します。 複数のURLパターンにマッチしうる場合も、最初にtrueを返した物のみが実行されます。

パラメータの取得

:から始まるパスにマッチした文字列は Argument として扱われ、ブロックから取得することができます。

("pokedex://pokemons/:pokedexID", { context in
    // URLからポケモンずかん番号を取得
    guard let pokedexID: Int = try? context.arguments(for: "pokedexID") else {
        return false
    }

    // 該当するポケモンを取得する
    guard let pokemon = Pokedex.find(by: pokedexID) else {
        return false
    }

    // ポケモン詳細画面を表示する
    presentPokemonDetailViewController(of: pokemon)
    return true
})

Argument はGenericsを利用しているので、任意の型として受け取ることができます。

例えば、pokedex://pokemons/25のURL Schemeからアプリを起動した場合、ずかん番号25番のポケモンが表示されます。

enumの値を取得する

Argumentを利用することで、それぞれのポケモンの詳細画面へ遷移するURL Schemeを実装することができました。

今度はポケモンを検索する画面を作ってみましょう。

URLのクエリとして渡された値は Parameter として扱われ、Argumentと同様にContextから取得することができます。

ここで、ポケモンのタイプを示すenum Typeを定義してみましょう。 Crossroadでは、Extractableというプロトコルに準拠させることで、任意の型をContextから返却することができます。

enum Type: String, Extractable {
    case normal // ノーマルタイプ
    case fire // ほのおタイプ
    case water // みずタイプ
    case grass // くさタイプ
    // ...
}

enumを表す型であるRawRepresentableは、すでにExtractableに準拠しているため、これだけで文字列をenumにマッピングすることができます。

("pokedex://pokemons", { context in
    let type: Type? = context.parameters(for: "type")

    // ポケモン一覧画面を表示する
    presentPokemonListViewController(of: type)
    return true
})

これで、pokedex://pokemons?type=fire というURL Schemeからアプリを起動すると、ほのおポケモンのみを表示する画面へ遷移することができます。

一般的な検索画面を実装する場合は、キーワードや並び順などをパラメータで受け取る実装が考えられるでしょう。

pokedex://search?keyword=ピカチュウ&order=asc

複数の値を取得する

ポケモンずかんを実装するに当たって、今度は複合タイプのポケモンをURL Schemeから検索したいという需要が出てくるでしょう。

Crossroadは、パラメータに渡されたカンマ区切りの文字列を配列としてマッピングする機能も提供しています。

// pokedex://pokemons?types=water,grass
let types: [Type]? = context.parameters(for: "types") // [.water, .grass]

これは、Swift 4.1から利用可能になった、Conditional Conformanceを用いて、[Extractable]Extractableに準拠させることで実現しています。

extension Array: Extractable where Array.Element: Extractable {
    static func extract(from string: String) -> [Element]? {
        let components = string.split(separator: ",")
        return components
            .map { String($0) }
            .compactMap(Element.extract(from:))
    }
}

独自の型を取得する

もちろん、独自の型を取得することもできます。Contextから取得したい型をExtractableに準拠させましょう。

struct Pokemon: Extractable {
    let name: String

    static func extract(from string: String) -> Pokemon? {
        return Pokemon(name: string)
    }
}
// pokedex://pokemons/:name
let pokemon: Pokemon = try? context.arguments(for: "name")

このように、Crossroadでは、柔軟にパスやクエリパラメータの取得を行うことができます。

Dynamic Member Lookupを使ったインターフェイス

最後に、Swift 4.2から実装される新たな言語機能であるDynamic Member Lookupを使ったインターフェイスの構想を紹介します。

Dynamic Member Lookupは、動的なプロパティ生成を提供するシンタックスシュガーです。 クラスや構造体に@dynamicMemberLookupを宣言することで、ランタイムで評価されるプロパティを生成することができます。

Dynamic Member Lookupを宣言すると、subscript(dynamicMember:) の実装が要求され、プロパティアクセスを行ったときに、プロパティ名が引数に渡され実行されます。

@dynamicMemberLookup
struct Container {
    let values: [String: Any]

    subscript<T>(dynamicMember member: String) -> T? {
        if let value = values[member] {
            return value as? T
        }
    }
}

let container = Container(values: ["name": "Pikachu"])
let name: String = container.name // Pikachu

本稿執筆時点では、Swift 4.2の正式版はまだリリースされていませんが、Swift.orgからdevelopmentのToolchainをダウンロードすることで、Xcode 9.3でも利用することができました *1

この機能をCrossroad.Contextに適用してみると、以下のように Argument を取得できるようになりました。

// match pokedex://pokemons/:pokedexID
let pokedexID: Int? = context.arguments.pokedexID

この実装はまだmasterへマージしていませんが、別ブランチで公開しているので、興味のある方は見てみてください。

まとめ

今回はCustom URL Schemeを簡単にルーティングするライブラリを紹介しました。 ぜひ利用を検討してみてください。もちろんPull Requestもお待ちしております。

技術部モバイル基盤グループでは、OSSを通して問題解決をしていきたいエンジニアを募集しています。

iOS アプリケーションエンジニア(開発基盤)

Android アプリケーションエンジニア(開発基盤)

*1:普通にビルドすることはできますが、静的解析の時点ではシンタックスエラーが発生します

オフィス・AWS環境をセキュリティ監視するためのログ収集

インフラストラクチャー部セキュリティグループの水谷 (@m_mizutani) です。

現在、クックパッドのセキュリティグループではセキュリティ監視を高度化に対して取り組んでいます。サービスに関連する部分の監視は以前からやってきたのですが、ここしばらくはそれ以外のインフラやオフィスで発生するセキュリティ侵害を検知することを目的とした監視基盤の構築に力を入れています。

昔は一般的にオフィス、インフラのセキュリティ監視と言えば、イントラネット内に閉じた環境でのログ収集から分析まで完結していたケースも少なくなったと考えられます。しかし現在だとインフラとしてクラウドサービスを多用したり、業務で使うツールをSaaSによって提供するという場面も増えているかと思います。このような状況だとセキュリティ監視のために見るべき箇所がばらけてしまうといったことが起こります。クックパッドでも積極的にSaaSやAWSを利用しており、個別のサービス毎にログは蓄えられているものの、それぞれを活用しきれていない状況が続いていました。この状況を改善するため、下記の図のように各所から必要なログをかき集めて利活用できる状態にすることが、「監視の強化」になります。

f:id:mztnex:20180529104148p:plain

必要なログを一箇所に集めてセキュリティ監視に利用できる状態を作ることで、以下のようなメリットがあります。

  • アラートの通知・対応フローを一本化できる: 監視している機器・サービスにおいて危険度の高いイベントが発生した場合にはセキュリティの担当にアラートとして通知してほしいですが、通知の方法が機器・サービス毎に異なると設定変更の漏れや、通知の受け取りをミスしてまう可能性もあります。例えば通知方法で言えば、メール通知のみ、syslogへの出力のみ、APIに問い合わせないとわからない、というようにバラバラなことはよくあります。また、通知するメンバーをローテーションしたりするといった管理もとても煩雑になってしまいます。これを一本化することで、どのような機器・サービスで発生したアラートでもスムーズに対応できるようになります。
  • ログの検索や分析が統一されたコンソールから実施できる: インシデントと疑わしい事象が発生したらそれが本当に影響があったのかをログを見ながら調査・分析しなければなりませんが、ログが適切に保存されているとしてもアクセスするためのインターフェース(コンソール)が分断されていると、検索や分析に支障がでます。経験したことがある方はわかるかもしれませんが、複数のコンソールをいったりきたりしながら様々な可能性を検証するという作業はかなりのストレスになります。また、作業が煩雑になり見るべきログを見逃したりログの相関関係を読み間違えることもありえます。これに対して、統一されたコンソールが用意されていれば同じキーワードで全てのログを一括して検索可能になりますし、データが正規化されていればまとめて分析することもでき、分析にかかる負担を大きく減らすことをできます。
  • ログの保全管理が容易になる: セキュリティ関連のログは監査の目的であったり、後から発覚したインシデントを調査するため、一定期間保全しておく必要があります。通常、各サービスのログもしばらくの間保全しておく機能を備えていますが、多くの場合は保全する期間がばらばらだったり、保全のポリシーが異なります。そのため管理が煩雑になり、必要な時にあるべきログが無いといったミスが発生しやすくなります。これを防ぐため、統一管理されたストレージにログを保全することで、保全の期間やポリシーの制御が容易になります。

この記事ではセキュリティ監視をするにあたって、実際にログの収集をどのように実施しているかを紹介し、さらに集めたログをどのように利用しているのかについても簡単に紹介したいと思います。

ログの収集

セキュリティに関連するログとして、主にインフラ(AWSサービス周り)のログ、オフィスネットワークのログ、スタッフが使う端末のログ、そしてスタッフが使うクラウドサービスのログという4種類を収集しています。こちらの資料でも少し触れていますが、全てのログは原則としてAWSのS3に保存し、その後S3から読み込んで利用するよう流れになっています。全てのログをS3に経由させるという構成は、以下の3つのポイントをもとに判断しました。

  • 可用性が高く、スケールアウトするか: ログに限りませんが大量のデータを継続的にストレージ入れる際、一時的に流量がバーストするというのは稀によくあることであり、ストレージ側はそれに対応するような構成になっている必要があります。特にDBなどは負荷をかけすぎるとそのままサービスが死んでしまうこともありえますし、かといって普段から余分にリソースを割り当てるとお金がかかります。一方、AWSのS3は投入しただけだと何もできませんが、高い可用性とスケールアウトを実現しているストレージサービスです。「ひとまず保存する」という用途ではスケールアウトを気にする必要もほとんどなく、安定して利用できるというメリットがあります。
  • ログ送信元と疎結合な構成にできるか: ログの収集・処理ではログの送信元からログを処理し終えるまでの一連のフローを考える必要があります。例えば一気通貫でストリーム処理をするような構成だと途中の処理に失敗したらまたログの送信元からやり直しということになってしまいます。しかし、一度S3に保存しておくことで何かあっても送信元まで遡る必要がなくなります。また、送信元のサービスを開発・運用しているのが別の主体だったとしても「とりあえずS3に保存してもらう」というお願いができれば、セキュリティのチームはそこから先の面倒だけ見ればよくなり容易に責任分解ができます。
  • 遅延が許容範囲に収まるか: 到着したログを順次処理していくシステムを作る場合、処理の目的によってどの程度の遅延まで許容できるかが設計に関わってきます。セキュリティ監視の観点で考えると一定のリアルタイム性が求められますが、その時間単位は必ずしも短くありません。例えば日本で大手のSOCサービスで監視および分析専属のスタッフがいる場合でも、通知や初動対応のSLAは10分〜15分程度となっています。クックパッドでも監視と分析をしていますが、専属のアナリストがいるというわけではないため対応に数分の遅れが出るのは運用上許容できると考えることができます。したがって、S3に一度ファイルを保存してから処理を実行しようとすると数分の遅延が発生する可能性がありますが、上記の理由により問題にはならないと判断しました。

加えて、S3にどのようなログを収集しているのかと、その収集の方法について紹介します。

インフラ(AWSサービス周り)のログ

クックパッドではサービスのためのインフラとして主にAWSを利用しています。インスタンスからのログ収集も必要ですが、それ以外にもAWSが様々なセキュリティ関連のサービスを提供しており、これも同様に収集して活用しています。

インスタンスのsyslog

各EC2インスタンスで発生するログを集約し、これもGraylogへと転送しています。こちらもここからインシデントを発見すると言うよりは、後からの調査に利用しています。インスタンスの種類によって細かい収集方法は様々ですが、基本的にはfluentdに集約され、その後でS3に保存されます。

CloudTrail

AWS上でのアクティビティをログとして記録してくれるサービスです。AWS上でのユーザやサービスの動きを追うことができるため、セキュリティインシデントに関連した分析だけでなく、デバッグやトラブルシュートにも利用されています。

CloudTrailは標準でS3にログを保存する機能があるため、これをそのまま利用しています。

GuardDuty

AWS上で発生した不審な振る舞いを通知してくれるサービスです。不審な振る舞いも深刻度でレベル分けされており、深刻度が低いものはEC2インスタンスに対するポートスキャン行為など、深刻度が高いものは外部の攻撃者が利用してるサーバと通信が発生していることを通知してくれます。

GuardDutyが検知した内容はCloudWatch Eventsとして出力されるため、これをLambdaで受け取ってS3に保存する、という構成で運用しています。

VPC flow logs

VPC内のインスタンスから発生する通信フローのログを保存しています。Flow Logsはインスタンスのネットワークインターフェースから発生した通信のフロー情報を全て記録してくれるため、アラート分析やインシデントレスポンスにおいて攻撃の影響判断や通信発生の有無を確認するのに役立ちます。

Flow logは通常CloudWatch Logsに書き出されるため、S3に格納するためにログデータのエクスポートを利用しています。

オフィスネットワーク関連のログ

NGFWのログ

通常のファイアウォールの機能に加え、通信の中身の一部も検査して脅威を検知・遮断してくれるNext Generation Firewall (NGFW) でオフィスネットワークの出入り口を監視しています。これによって怪しいサイトやマルウェアを配布しているようなサーバへのアクセスが検知され、場合によって自動的にブロックされます。

NGFWからは通信フローのログ、および脅威の検知やブロックのログの両方を収集しています。現在利用しているNFGWはsyslogでログを飛ばせるので、fluentdを経由してS3 output pluginを利用し、S3に保存しています。

DNSキャッシュサーバのログ

弊社で利用しているNGFWは通信のTCP/IPフローのレベルの情報は残してくれますが、IPアドレスとポート番号および通信量などの情報しかわからないので、どのような通信がされていたのかは把握が難しい場合があります。そこで名前解決のためのDNSのログをとっておくことで、分析のための材料を増やしています。IPアドレスだけだと判断が難しくても、どのドメイン名のホストと通信したかがわかることでインシデントを追跡できることがしばしばあります。

オフィスではDNSのキャッシュサーバとして unbound を利用していますが、出力されるログの形式が利用しづらいことから、unboundのログは直接利用していません。代わりにキャッシュサーバ上で packetbeat を動かしてパケットキャプチャし、DNSのログを収集したものを fluentd + fluent-plugin-beats を経由してS3に保存しています。

DHCPサーバのログ

現在クックパッドではスタッフが使うPCに端末管理のソフトを導入していますが、このソフトは端末がどのIPアドレスを利用していたかを過去にさかのぼって調べられません。そのため、DHCPのログも別途保存しておくことで、後から分析する場合でもそのIPアドレスを使っていたのがどの端末だったのかを追跡できます。

クックパッドでは kea DHCP server を運用しています。標準で出力されるログに必要な情報が載っているので、fluentdのtail input pluginでAWS S3にログを保存しています。現状では kea DHCP server ログを読み込むための成熟した専用 fluentd plugin は無いようだったのでひとまず1行1ログとして扱い、後からパースして必要な値を取り出すようにしています。

スタッフが使う端末のログ

アンチウィルスソフトのログ

クックパッドではアンチウィルスソフトとしてCylance PROTECTを採用しており、マルウェアと疑わしいファイルの検知情報をアラートとして利用しています。

この製品はマルウェアと疑わしいファイルや振る舞いを検知した後にログをSaaS側で収集してくれます。現状ではLambdaから定期的にSaaSのAPIをポーリングし、新しいイベントが発生していることを検出した場合はS3に新たにログファイルを生成するようにしています。

スタッフが使うクラウドサービスのログ

G Suite

書類、メール、カレンダーなどを一通り利用しているG Suiteでは各操作のログを取得しています。これによって何か問題が発生したときにスタッフが業務で利用している書類がどこかへ持ち出された形跡があったかどうかなどを調べることができます。

G SuiteではReports APIが用意されており、これを使ってAdmin activity, Google Drive activity, Login activity, Mobile activity, Authorization Token activityを取得することができます。これらAPIにLambdaで定期的にアクセスし、ログデータとしてS3に保存しています。

Azure AD

Azure Active Directory (AD) は Single Sign On のサービスとして利用しており様々な外部サービスを利用する際の認証のハブになっています。こちらも何か問題があった場合にどの端末からどのサービスへのアクセスがあったかを辿ることができます。

Azure ADも監査APIが用意されており、ログインなどに関連するログを抽出することができます。これをG Suiteと同様に定期的に実行し、新しく取得できたものについてログデータとしてS3に保存しています。

集めたログの利用

本記事のテーマはログの収集ですが、集めた後にどのように利用しているかについても簡単に紹介します。S3に集められたログは現在は主にアラート検出とログ検索・分析の2つに利用されています。

アラート検出

ここでいうアラートとは「影響があったかどうか確定ではないがセキュリティ侵害があったかもしれない」という状況を指します。現在、開発の都合で複数のログを組み合わせたいわゆる「相関分析」まではできていませんが、インシデントの懸念があるログが到着した場合にアラートとしてセキュリティグループのメンバー(担当持ち回り)で発報して対応を促します。具体的には以下のようなログを利用しています。

  • NGFWがアラートとして不審なサイトなどへのアクセスを捉えた時
  • Cylanceがマルウェアと疑わしいファイル・プロセスを発見した時
  • GuardDutyが一定以上の深刻度があるイベントを報告してきた時

これらのログが発見された場合、統一された形式にログを変換した上でアラートとして発報されます。担当のセキュリティグループのメンバーは誤検知か否かを分析し、影響があるかもと判断された場合は然るべき人に協力を仰いだり、PCそのものやログの追加調査を実施します。

f:id:mztnex:20180529104217p:plain

検出されたアラートはPagerDutyによって担当のメンバーに通知される

ログの検索・分析

冒頭で述べた通り、集めたログは横断的にログを検索できることが真価の1つになります。クックパッドではログ検索のために Graylog を導入して検索コンソールを構築しています。現状、VPCのフローログやサービスのログといった劇的に流量が多いログについては投入せずにAthenaで検索できるようにするなど工夫をしていますが、それ以外については概ね先述したログをカバーできるようになっています。

これらのログはセキュリティグループのメンバーだけでなく、スタッフは(一部を除いて)ほぼ全てのログにアクセスできるようになっています。そのため、セキュリティの用途だけではなくインフラなどが関係するトラブルシュートにおいても利用されることがあります。

日常的な運用でこのログをひたすら眺めるというようなことはしていませんが、アラートを検出した時に関連するログを調べるために利用するケースが多いです。そのアラートの前後に発生したログや関連するキーワードが含まれるログを読み解くことで、そのアラートは我々にとってセキュリティ侵害の影響をおよぼすものなのか?ということを判断しており、このような作業をログの分析と呼んでいます。複数のコンソールをとっかえひっかえすることなく、キーワードと時間帯を指定するだけで関連するログの分析ができることで、アラート検出時に必要な作業量・時間を大幅に短縮しています。

例えば、GuardDutyから「あるユーザが普段と異なるネットワークからAPIを操作している」というアラートが発報されたとします。その時、Graylog で該当ユーザ名とその前後の時間帯を検索すると例として G Suite でサービスにログインしたログ、そのユーザ名についてメールでやりとりされたログ、AWSで他のAPIを発行したログを一気通貫で見ることができます。これによって、そのユーザがその時間帯にどのような活動をどのネットワークからしていたのかがワンストップで確認できるようになり、それが正規のユーザの活動だったのか、それとも外部の悪意あるユーザがなりすましていたのかを容易に判断できるようになります。

f:id:mztnex:20180529104231p:plain

関連するキーワードと時刻を入力すると複数種類のログを時系列順に一括して見えるのでスムーズに全体像を把握することができる

実装の構成

ここまで解説したログの収集・分析の基盤は以下の図のような構成になります。

f:id:mztnex:20180529104346p:plain

ログ収集のパートで説明したとおり様々な手段を使っていますが、ログは必ずS3に集約するようになっています。S3のバケットはログの種類やサイズ、他のコンポーネントとの兼ね合いで分かれており、流量が大きいものは専用のバケットを用意して、そうでもないものは1つのバケット内でprefixだけ分けるようにしています。

S3にログが保存されると AWS Simple Notification Service (SNS) にイベント通知が飛び、そこからログの転送やアラート検知に関する処理が実行されます。それぞれの機能はAWS CloudFormation (CFn) によって構成されています。このCFnの中身については別記事にて解説していますので、興味がありましたらご参照いただければ幸いです。

まとめ

今回の記事ではオフィスやSaaS、それにインフラ(AWS)からなぜログを収集するのか、そしてどのようなログをどのように収集しているのかについて事例を紹介させて頂きました。クックパッドでは現在、ログ収集だけでなくアラートの検出やアラート発生時の対応に関する取り組みもセキュリティ監視の一環として進めているため、それらについてもいずれ紹介できればと考えています。

Railsアプリケーションでフォームをオブジェクトにして育てる

ユーザーエンゲージメント部の諸橋 id:moro です。

わたしはずっと、ユーザー登録やログイン周りという、サービス的には基盤的なところ、技術スタック的にはアプリケーション寄りのところに取り組んできました。関連する話を何度かこの開発者ブログにも書いています。

今日はそのあたりの開発を通じて考えた、Railsアプリケーションでのフォームオブジェクトやサービス層といったものが何であるか、という問いに対する、現在の自分のスタンスを紹介します。

サービス層、サービスオブジェクト、フォームオブジェクト

もともと Railsは Web 画面から DB 構造までをあえて密に結合させることで、簡単なサービスを高速に開発するフレームワークとしてデビューしました。と同時に、 Web アプリケーションフレームワークとしての使い勝手の良さや時流も手伝って、そう単純でないサービスを作るのにも使われるようになりました。

そうした背景も踏まえて、この数年は Rails の設計に関する興味も高まってきており、MVC だけでないレイヤの導入や DDD の諸アイディアの適用への興味も高まっているように思います。 中でもよく参照される考え方は、Code Climate 創業者の @brynary さんによる 7 Patterns to Refactor Fat ActiveRecord Models *1 でしょう。

この記事では、モデルにまとめて書かれがちな処理を、Form Object や Service Object に分けていくことが提案されています。

あるいは、Trailbrazer や Hanami といった after Rails 世代のフレームワークにおいても、Operation や Interactor と呼ばれるレイヤやコンポーネントによって、Rails の素朴な MVC におさまりきらない処理を記述する場が用意されています。

筆者も、「ユーザー基盤の切り出し」として新たに Rails アプリケーションを作るにあたり、このあたりのアイディアをおおいに参考にしました。その上で、これらはつまり

『ActiveRecord::Base を継承し、永続化を中心に、バリデーションやコールバック、アクセサといった責務が詰まったモデル』や『HTTP リクエストを受けたり、処理結果のレスポンスを組み立てる責務をもったコントローラ』といったコンポーネントをそれぞれの責務に集中させたい、そのうえで必ずしもそのように分類できないアプリケーションのドメイン独自のロジックを書く場所がほしい、ということでないかと考えました。

このあたりで考えていることは、前回記事でも触れています。 そこで、分割した責務を配置する層を便宜上「フォームオブジェクト」と呼び、全体として収まりが良いコードになるように育てていくことにしました。 *2

またその際、新たなフレームワークを導入するのではなく、勝手知ったる Rails の上に app/forms というレイヤを作りできるだけプレーンな Rails (とは?) の構成ではじめました。

このレイヤのオブジェクトに求めるもの

フォームオブジェクトやサービス層、あるいは Interactor や Operation などいろいろな呼び方やそれに伴う視点の違いはありますが、共通しているのは以下の点です。

  • 外部入力(Railsで典型的なのはWebリクエスト、より具体的に言うと ActionController::Base#request や 同#params )とドメインロジックを分離する。
    • ドメインロジックへの入力が単純なオブジェクトになり、入出力とロジック部分の境界がはっきりする。
    • ActiveJob として起動される非同期ジョブにしたくなった場合も、入出力境界がはっきりしているため、簡単に移行できる。
    • ユニットテストも書きやすくなる。
    • より高レベルなテストにおけるデータセットアップにて本物のロジックを呼び出せる。テストでも「本物の」データグラフを使うことができる。
  • Rails の素朴なアクティブレコードパターンだけでは収まらない処理を担う。
    • 複数のARモデルを一度に永続化するときに、データを組み立てる場がある。
    • 上記のシーンで頻出するものの扱いの難しい accept_nested_attributes_for を避けられる。
    • ActiveRecord モデルクラス (以下ARモデル) ではなくコンテキストに依存にする、バリデーション・コールバックを扱う。
    • 入力がリクエストパラメータやHTTPヘッダではなく、ただの文字列や数値になるため。

逆にいうと、処理を行うクラスを app/models/* に配置しつつ上記のようなアプリケーション内での境界に留意するのであれば、無理に新しいレイヤを導入する必然性は低いでしょう。

そのあたりは、チームの中で合意形成するのがよいと思います。

以降、実際のアプリケーションへのフィーチャ追加を通じて、このフォームオブジェクトの有り様を抽出しブラッシュアップしていった過程、並びにそこから考えたことを紹介します。

サービスの形をオブジェクトにする

以前に紹介したように最近、電話番号でもクックパッドへのユーザー登録できる、という大きなフィーチャをリリースしました。 これは、従来メールアドレスの登録を前提としていたクックパッドへのユーザー登録を、SMSで所有確認をした携帯電話番号でも登録できるようにする、というものです。

ひとくちに電話番号でのユーザー登録といっても、このフィーチャを実現するには文字通りの「登録」だけでは足りません。実際は以下のような機能がすべて必要になります。

  • 未登録のサービス利用者が、電話番号で新規にユーザー登録できる機能
    • 完全に新規の場合と、電話番号登録前にすでに有料サービスを利用開始している(システム的にはユーザーデータが存在する)場合がある。
  • メールアドレスとパスワードで登録したユーザーが、電話番号も追加登録できる機能
  • 電話番号を登録したユーザーが、その電話番号を変更できる機能
  • パスワードを忘れたユーザーが、電話番号の所有確認をしてパスワードリセットできる機能

できるだけ素直にフォームオブジェクトにしようとしていたため、「電話番号での新規ユーザー登録」の時点は無理に共通化しようとせず、完全新規の場合とすでに有料サービスを利用しているケースでそれぞれ一揃い個別のフォームオブジェクトを作りました。

最初の2例程度は良かったのですが、さすがに冗長に感じてきたため「電話番号の追加登録」の実装に入るタイミングでもう一度よく考えてみました。

すると、これらの機能群はすべて「ユーザーが入力した電話番号に対し認証コードを含むSMSを送信し、その認証コードが一致していたら電話番号を所有している本人であるとみなす」(以下『認証コード突合』)という振る舞いを含んでいることに気づき、その方向でコードを整理していくのがよさと考えました。

とはいえ、コードの字面や現時点での動きが似ているからという理由だけでコードを安易に共通化すべきではありません。たまたま現在の挙動が同じであるのか、それとも対象ドメインで同一でみなして良いものであるのかをよく考え、共通化する範囲や方法を考えるべきです。そこでコードの事情からはいったん離れ、ドメインエキスパートと一緒に共通化の方向性を探ることにしました。

結果として「『認証コードの突合』と"何か"をする」という継承 + テンプレートメソッドパターンの作りではなく、「何かする過程において共通の『認証コード突合』をし、続きを行う」というコンポジション的な構造となるように共通化するようにしました。ドメインエキスパートとともにユーザーから見える振る舞いを考えても、「『電話番号での登録』is a『電話番号を確認してなにかする』である」「『電話番号の追加』is a『電話番号を確認してなにかする』である」というのはピンとこず、コンポジションになっているほうが違和感がないとのことでした。

こうではなく f:id:moro:20180529182315p:plain

こう f:id:moro:20180529182320p:plain

見出した形に向けてリファクタリングする

技術的にもサービスの概念的にもこの『認証コードの突合』が抽出できそうということが分かってきたので、その方向にリファクタリングしていきます。まず「電話番号の追加登録」の構造は以下の3種類の画面に分割することができそうです。

  1. 最初にユーザーが電話番号を入力するフォーム (PhoneNumberAdditionForm) のある画面
  2. 認証コードSMSを送信し『認証コードの突合』をするフォーム (PhoneNumberAddition::VerificationForm) のある画面
  3. 突合に成功したあとに、電話番号データを永続化し、そのあとで正常に追加できた旨を表示する画面

このうち 2.の『認証コードの突合』を行うフォームに必要な振る舞いを詳細に見ていくと、次のようになります。

  • 規定回数・時間内に、正しい認証コードを入力したとき、次のステップに進む
  • 一定の有効期限を過ぎてから照合された場合、最初から入力を促す
  • 間違った認証コードが入力された場合、規定回数以下であれば再入力を促す
  • 規定回数を超えて間違った認証コードが入力された場合、最初から入力を促す

そのあたりを踏まえて、このような構造にしました。

  • コード照合、その結果取得、失敗回数などによる再入力可否の判定を ::PhoneNumberVerificationForm として抽出した。
  • 「次のステップ」を導出するために必要な振る舞いを PhoneNumberAddition::VerificationForm に残した。
  • コントローラからは PhoneNumberAddition::VerificationForm を参照する。
    • 照合結果取得メソッドなどを ::PhoneNumberVerificationForm に委譲する。
    • 委譲の宣言を含め、最終的に結合させる部分のみ、小さな module にして mixin する。
  • コントローラは、 #認証に成功した? が真の場合、 #次の遷移先を表すリソースを取得 してリダイレクトする。

このようにすることで、個別のフォームクラスの責務がはっきりし、コードも整理できました。

f:id:moro:20180529182411p:plain

その後引き続き「電話番号の変更」や「電話番号でのパスワードリセット」を実装していきましたが、目論見通り PhoneNumberVerificationForm の修正はほとんど不要でした。これまた振る舞いの観点から言い換えると、すでに作って共通化された電話番号の所由確認の仕組みを使い、電話番号変更機能に必要なぶんのみ実装することで、機能追加できたことになります。

横展開のイメージ

f:id:moro:20180529182430p:plain

「フォーム」とはなにか

このように、アプリケーションのドメインを見ながらコードをリファクタリングしていった結果、あるフォームオブジェクト PhoneNumberAddition::VerificationForm の中から別のフォームオブジェクト PhoneNumberVerificationForm を呼び出す構造となりました。アプリケーションへのおさまり具合はよかったものの、これは「フォームオブジェクト」という名前から想像する、画面の入力項目を表す「フォーム」の形からは大きく異なります。

そこで、もしかして「フォーム」のもともとのニュアンスは違ったりしないかな、と思い調べたり Twitter でも聞いてみたりしましたが、やはり入力フォームからきているようでした。*3 そのため独自研究の単語連想ゲームにはなってしまうのですが、この「フォーム」をアプリケーションにおけるドメインの「形」を写し取ったものと解釈できないかと思っています。

題材としているフィーチャには、電話番号の登録や変更という「形」があり、これらは一連の機能のエントリポイントであるため目に付きやすいです。いっぽうその一部分と認識されがちな『認証コード突合』フローも、それ自体が多くの機能を持った大事な「形」として扱い、抽出して独立させました。

さらにフィーチャ全体では、電話番号の変更や、電話番号によるパスワードリセットといった機能も必要となりました。この場合でも共通する『認証コード突合』をそのまま使いつつ、エントリポイントとして各機能を実装しました。結果、コードの追加量的にも、必要な工数的にも納得できる程度でつくることができました。フィーチャに対してよいモデルを作れたのではないかと思います。

冒頭でも触れたように、このようなRailsコントローラ層とモデル層の間にもう一層設け、ドメインの複雑な処理をそこに配置するという設計手法は、一般的になってきました。それを「フォームオブジェクト」と呼ぶか、あるいは「サービス層」と呼ぶかに関しては、筆者は実はそこまでのこだわりはありません。

一方、大事だと思うのは、そこにドメインの形が現れてくるように作る、あるいは現れるように継続的に手を入れていくことです。今回の例ではこのように『認証コード突合』を抽出しましたが、今後また新たな要求を実現すべく眺めた場合、別の形(フォーム)が浮かび上がってくるかもしれません。それを見逃さずに、柔軟に育てていけるようでありたいと思っています。

まとめ

ドメインのありように注意を払いながら、フォームオブジェクトを育てていった話をしました。

  • ドメインに関する処理を Web の入出力と分けるためにフォームオブジェクトを導入した。
  • 継続的にリファクタリングしコードを育てているうちに、フォームオブジェクトの構造とドメインの構造が一致した。
  • その経験から、「フォーム」という語について考察してみた。入力フォームとしてだけでなく、ドメインの形(フォーム)であることを意識すると、エンジニアだけでないチームみんなで同じ視点からソフトウェアを見られた。
    • スッキリハマったときは、とてもたのしかった!

こういうふうにアプリケーションの形を彫り出してみると、コードもスッキリするし、テストもしやすいし、ドメインエキスパートはじめチームの色んな人と認識が一致して、とても楽しい体験だった、という一つの小さなストーリーを紹介しました。お題となったフィーチャ自体はとてもニッチかと思いますが、なんらかみなさんのお役に立つとうれしいです。

明日 5/31 から RubyKaigi 2018 ですね。クックパッドでも、社員が発表したり、各種パーティーなどを企画しています。ブースなどもありますので、ぜひお立ち寄り下さい。もちろん、筆者も参加予定です。

たのしい RubyKaigi と、その後も続くよいソフトウェア開発を!

*1:@hachi8833 さんによる 邦訳

*2:前回記事の時点だと「サービス層」と読んでいましたね。後述のように、そこの呼称じたいへのこだわりはあまりなくなってきています

*3: https://twitter.com/moro/status/965444586276466690

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/