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

クックパッドは、RubyKaigi 2018 でみなさんにお会いできることを楽しみにしています!

こんにちは! 広報部のとくなり餃子大好き( id:tokunarigyozadaisuki )です。

クックパッドは RubyKaigi 2018にRuby Committers SponsorとNetwork Sponsor として協賛します。 Ruby Committers Sponsor とは、「Ruby Committers vs the World」に参加されるRubyコミッターの交通費をサポートするものです。 また、Network Sponsor に関しては、会場ネットワークの設計・構築・運用などを @sorah が担当しております。

そして、クックパッドに所属する5名(@pocke@riseshia@wyhaines@ko1@mame)が登壇し、4名(@nano041214@asonas@sorah@mozamimy )が運営として関わってくれています。

ブース出展やドリンクアップ開催もいたしますので、そちらも合わせて紹介します。 クックパッド社員は約40名参加しますので、みなさまと交流することを楽しみにしています。

登壇スケジュール

はじめに、社員が登壇するセッションのスケジュールを紹介します。

1日目 5月31日(木)

  • 16:40-17:20 桑原仁雄(@pocke):A parser based syntax highlighter
    @pocke が作成したIroというgemについてお話します。このgemは、Rubyのシンタックスハイライターです。今回はその特徴と実装についてを紹介します。
  • 17:30-18:30 ライトニングトーク
    • Sangyong Sim(@riseshia):Find out potential dead codes from diff
      Rubyで静的に未使用コードを探す時に間違って検出してしまうのを減らす方法について紹介します。

2日目 6月1日(金)

  • 10:50-11:30 Kirk Haines(@wyhaines):It's Rubies All The Way Down
    通常、Webアプリケーションスタックでは、アプリケーションそのものの処理にRubyを使用し、それ以外のレイヤーはRuby以外の言語で書かれているものをつかいます。発表では、その他のレイヤーについても、Rubyにしてみたらどうなるかを見ていきます。
  • 13:00-13:40 笹田耕一(@ko1):Guild Prototype
    今開発中の、Ruby 3 の並列並行処理のための新機能 Guild について、そのプロトタイプと実装方法を紹介します。
  • 16:40-17:20 遠藤侑介(@mame):Type Profiler: An analysis to guess type signatures
    Ruby3 の静的解析の構想をお話します。特に、Ruby プログラムから型情報を推定する型プロファイラの試作や検討状況に関する報告です。
  • 17:30-18:30
    • Cookpad Presents:Ruby Committers vs the World こちらの時間では、Cookpad Ltd CTOの Miles Woodroffe がご挨拶いたします。また、笹田耕一と遠藤侑介が司会を務めます。

3日目 6月2日(土)

  • 16:40-17:40 遠藤侑介(@mame):TRICK FINAL
    TRICK FINALとは、@mameが主催する変な Ruby プログラムで競い合うプログラミングコンテストです。 こちらの時間ではその結果を発表し、入賞作品を解説します。全く読めない、何の役にも立たない、実現不可能としか思えない珠玉の Ruby プログラムたちを楽しみましょう。

ブース

RubyKaigi 2018ではブースの出展もしております。下記スケジュールの通りライブコーディングや登壇者へのQ&A タイムなど、様々なプログラムを予定しております。グッズの配布も行いますので、ぜひお立ち寄りくださいね! 

1日目 5月31日(木)

  • 15:20-15:50 午後休憩: Cookpad live coding by @hokaccha
    @hokaccha がクックパッドのサイトを変更・デプロイする様子をブースでライブコーディングします。

2日目 6月1日(金)

  • 12:00-13:00 ランチ休憩:Q&A タイム by @pocke
    この時間は、クックパッドブースに@pockeがおりますので、1日目 5月31日(木)16:40-17:20 A parser based syntax highlighter に関するご質問がある方は、ぜひこの時間にブースにて、本人に聞いてみてください。
  • 15:20-15:50 Global Office Hours
    クックパッドは、海外事業の全てを統括する第二本社をイギリス・ブリストルに開設しサービス展開を進め、展開国数は現在68カ国となりました。本時間には海外勤務経験のある社員がブースにおります。海外で働くことに興味がある方は、ぜひお気軽に話しを聞いてみてください! 

3日目 6月2日(土)

  • 12:00-13:00 ランチ休憩:Q&A タイム by@wyhaines
    この時間は、クックパッドブースに@wyhainesがおりますので、2日目 6月1日(金)10:50-11:30 It's Rubies All The Way Down に関するご質問がある方は、ぜひこの時間にブースにて、本人に聞いてみてください。
  • 15:20-15:50 午後休憩:Ruby interpreter development live
    @ko1@mame によるRuby インタプリタのライブコーディングです。登壇時の発表内容に関してご質問がある方も、この時間にお声がけください。 

Drink Up

Cookpad X RubyKaigi 2018: Day 2 Party

Cookpad international HQ team is hosting a party in the evening on Day 2 of RubyKaigi 2018. Come along to network, meet other Rubyists and perhaps a Ruby committer or special guest or two. How exciting!

www.eventbrite.com.au

Asakusa.rb × Cookpad「Meetup after RubyKaigi 2018」

Asakusa.rb × Cookpadのコラボレーションで、RubyKaigi 2018のアフターイベントを弊社オフィスにて開催します。美味しいお酒とご飯を食べながら、RubyKaigi 2018について振り返りましょう。 懇親会の時間もたっぷり取っていますので、お楽しみいただけたら幸いです。詳細は下記よりご確認下さい。

※ご好評を頂き、全て満席となりましたのでご了承下さい。たくさんのお申し込みありがとうございました。 cookpad.connpass.com

おわりに

会場でクックパッド社員をお見かけの際には、ぜひお声がけください。また、発表内容へのご質問やクックパッドにご興味をお持ちの方は、上記スケジュールをご確認の上、お気軽にブースまでお越しください。みなさまにお会いできることを楽しみにしております。

AWS Lambda@Edge で画像をリアルタイムにリサイズ&WebP形式へ変換する

技術部の久須 (@) です。クックパッドではモバイル基盤グループにて Android 版クックパッドアプリの開発・メンテナンスに携わっています。

普段の業務とは少し異なるのですが、画像リクエストに応じリアルタイムに画像を変換してレスポンスするという仕組みを AWS の Lambda@Edge を用いて実現してみたので、構築した環境の内容やコードを紹介したいと思います。画像変換の内容はコードの実装次第で大概のことは実現できそうですが、今回のコードの内容はスマホ向け WEB サイトやモバイルアプリ向けの画像配信を想定し、通信容量の削減および表示速度の向上を目的とした画像のリサイズ(主に縮小)と WebP 形式への変換です。

注意:実運用している段階ではないので参考にされる場合はご注意ください。ちなみにクックパッドには本番環境とは切り離された調査・検証用の AWS 環境があり、今回の投稿のようにエンジニアは自由に AWS の各種コンポーネントを試すことができます。

概要

Lambda@Edge とは、公式ドキュメント にも説明がありますが CDN である CloudFront の入出力 HTTP リクエスト・レスポンスを操作できる Lambda 関数です。今回は CloudFront のオリジンとして S3 を指定し、S3 からの画像レスポンスを Lambda@Edge で変換する仕組みを構築しました。

f:id:hkusu:20180524153623p:plain

この仕組みでは画像へのリクエストに応じてその場で画像変換を行うので、サービスの運営において様々なバリエーションの画像が必要な場合であってもそれらを予め用意しておく必要がなく、画像を変換する為のサーバも必要としません。S3 に画像ファイルさえ置けばよいのでサーバサイドのアプリケーションの種類や言語を問わず、たとえ静的な WEB サイトであったとしても様々なバリエーションの画像を提供することができます。

変換後の画像は CloudFront にキャッシュされるので、変換処理が行われるのは CloudFront にキャッシュがない場合のみです。Amazon Web Services ブログ では変換後の画像を S3 に保存する方法が紹介されていますが、今回の方法では変換後の画像は CloudFront のみに持つ構成としています。そもそも CloudFront のキャッシュ期間を長く設定しておけばよいという話もありますが、たとえ CloudFront のキャッシュがきれた場合でも画像変換を再度実行するのではなく CloudFront のキャッシュの保持期間を延長することで変換コストを抑えることができます(この方法については後述します)。また変換後の画像をキャッシュでしか保持していないので、後から画像変換の仕様が変わったり不具合があったりしたとしても S3 上の画像を消す等のオペレーションを必要とせず、CloudFront 上のキャッシュを消す(CloudFront に invalidation リクエストを送る)だけで対応できます。

Lambda@Edge を利用する上での注意点

まず Lambda@Edge 用の関数の開発で利用できる言語は Node.js かつバージョンは 6.10 のみです。旧来の Lambda 関数の開発で利用できる 8系 は現時点で対応していません。また Lambda@Edge 用の関数を作成できるのは米国東部(バージニア北部)リージョンのみです(ただし作成した関数は各 CloudFront のエッジへレプリケートされます)。

そのほか制限は公式ドキュメントの Lambda@Edgeの制限 のとおりです。注意すべきは Lambda@Edge でオリジンレスポンス(今回の構成では S3 からの画像レスポンス)を操作する場合、操作後のレスポンスのサイズはヘッダー等を含めて 1MB に抑える必要があることですが、通常のWEBサイトやモバイルアプリでの用途としては十分な気がします。タイムアウトまでの制限時間は 30 秒と長く、メモリも最大 3GB ほど使えることから画像を扱う環境としては問題なさそうです。

また、これは Lambda@Edge 用の関数の実装時の制約なのですが、関数からオリジンレスポンスを取り扱う際、関数からはレスポンスBody(画像データ)にアクセスできません。よって、改めて関数から S3 へアクセスし画像ファイルを取得する必要があります。

環境の構築 (CloudFront、S3)

特筆すべきことはなく S3 のバケットを作成し、それをオリジンとして CloudFront を設定すれば問題ありませんが(ブラウザ向けに HTTP/2 もサポートするようにしておきましょう)、クエリ文字列はフォワード&キャッシュのキーに含める(クエリ文字列が異なれば別ファイルとしてキャッシュする)ようにしてください。これは後述しますがクエリ文字列で画像の変換オプションを指定する為です。また CloudFront のキャッシュ期間も適宜、設定しておきます(動作確認中は1分など短くしておくとよいです)。

f:id:hkusu:20180524154845p:plain

環境の構築 (Lambda@Edge)

Lambda@Edge の環境構築については 公式ドキュメント に詳しいでここではポイントのみ述べます。

ロールの作成

AWS の IAM にて予め Lambda@Edge 用の関数の実行ロールを作成しておきます。

アタッチするポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::*"
            ]
        }
    ]
}
  • S3 の画像を参照する権限を追加

信頼関係

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": [
          "lambda.amazonaws.com",
          "edgelambda.amazonaws.com"
        ]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
  • edgelambda.amazonaws.com を追加

関数の作成

Lambda@Edge 用の関数の実装を用意する前に、管理コンソール上で関数の枠組みだけ作成しておきます。米国東部(バージニア北部)リージョンの Lambda のメニューから関数を作成します。

f:id:hkusu:20180524154855p:plain

[関数の作成] ボタンを押下し関数を作成します。選択した実行ロールの情報を元に、本関数からアクセスが可能な AWS のリソースが次のように表示されます。

f:id:hkusu:20180524154933p:plain

問題なければ一旦、枠組みの作成は完了です。

関数の実装の用意

実装例として、サンプルコードを私の方で作成しました。GitHub に置いてあるので参考にしてください。
hkusu/lambda-edge-image-convert

サンプルコードの説明

画像のリサイズと WebP 形式への変換の機能を提供します。仕様は次のとおりです。

  • 変換元の画像は JPEG 形式の画像のみ

  • リサイズの際に画像のアスペクト比(横幅と縦幅の比率)は変更しない

  • 変換オプションはクエリ文字列で指定する

    キー デフォルト 最大値 補足
    w   最大横幅(ピクセル)を指定 1200    1200   変換元の画像より大きな値は無効
    (=拡大しない)
    h 最大縦幅(ピクセル)を指定 同上 同上 同上
    p t (true):WebP 形式へ変換する
    f (false):WebP 形式へ変換しない
    f (false) - -
    • https://xxx.com/sample.jpg?w=500&p=t
    • w h で「最大」としているのは最終的に適用される値はアスペクト比を維持しながら決定される為
  • 変換後の画像品質(quality)は一律で変換元画像の 80% とする

  • 変換後の画像のメタデータは全て削除する(意図せず位置情報等が露出するのを防ぐ為)

この仕様だとメインロジックは index.js 1ファイルに収まりました。メインロジックを少し補足をします。

l.7〜l.12

let sharp;
if (process.env.NODE_ENV === 'local') {
  sharp = require('sharp');
} else {
  sharp = require('../lib/sharp');
}

画像変換には sharp というライブラリを利用しています。このライブラリのランタイムは実行環境により異なる為、ローカルでは開発環境の構築時にインストールされた node_modules/sharp を利用し、AWS 上で Lambda 関数として実行する際は lib/sharp ディレクトリのものを利用するようにしています。

サンプルコードのリポジトリには lib/sharp ディレクトリは含まれていません。AWS 上で Lambda 関数として動かすには、EC2 等を構築して Amazon Linux 上で $ npm install sharp を実行し、生成された node_modules/sharp ディレクトリを中身ごと lib ディレクトリ配下へ配置してください。

l.18〜l.19

exports.handler = (event, context, callback) => {
  const { request, response } = event.Records[0].cf;

関数への入力として渡される event オブジェクトから request オブジェクト、response オブジェクトを取り出しています。

l.36〜l.39

if (response.status === '304') {
  responseOriginal();
  return;
}

CloudFront 上のキャッシュがきれた場合、S3 に対して ETag 付きの条件つきリクエストを送ってきます。S3 からは 304 コードが返ってくるので、この場合は何もせずレスポンスをスルーして終了します。CloudFront が 304 レスポンスを受け取った場合、キャッシュの破棄ではなくキャッシュの保持期間の延長が行われます。

l.77〜l.82

s3.getObject(
  {
    Bucket: BUCKET,
    Key: options.filePath.substr(1), // 先頭の'/'を削除
  })
  .promise()

非同期の処理を行うにあたりコールバックのネストが深くなってしまうので、可読性の向上を目的に Promise インタフェースを利用しています。

l.85

return sharpBody.metadata();

変換前の画像のメタデータを取得しています。ただし取得は非同期です。

l.95

sharpBody.resize(options.width, options.height).max();

画像のリサイズを行っています。ここで .max() を指定することにより画像のアスペクト比が維持されます。

l.99〜l.101

return sharpBody
  .rotate()
  .toBuffer();

sharp では .withMetadata() を指定しない限り、変換後の画像のメタデータは全て削除されます。この際、画像の orientation(向き) の情報も削除されてしまう為、変換後の画像をブラウザ等で表示すると画像の向きが反映されていません。 .rotate() を指定すると、画像の向きが合うよう画像データそのものが回転されます。

また、今回 .quality() での画像品質の指定は行っていないので、デフォルトの 80 が適用されます。

l.104〜l.112

response.status = '200';
if (options.webp) {
  response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/webp' }];
} else {
  response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/jpeg' }];
}
response.body = buffer.toString('base64');
response.bodyEncoding = 'base64';
callback(null, response);

画像をレスポンスするコードです。 Content-Length ヘッダはここで設定しなくても AWS 側で自動で付与されます。

今回、S3 からのレスポンス(response変数)をそのまま利用し必要な箇所だけ上書きしている為、ETag Last-Modified ヘッダはここで再設定しない限り S3 から返されたものがそのまま CloudFront に渡ります。変換オプション毎に変換後の画像データは異なる為、ETag Last-Modified ヘッダも変換オプション毎に変更した方が良いと考えるかもしれません。ただ CloudFront でクエリ文字列込の URL ベースでキャッシュするようにしている場合は、ETag Last-Modified ヘッダは共通で問題ありません。変換オプションが異なれば URL も異なるので、別ファイルとしてみなされるからです。

もし、レスポンス時にクライアント側や CloudFront のキャッシュを制御する場合は response.headers['cache-control'] = [{ key: 'Cache-Control', value: 'max-age=604800, s-maxage=31536000' }] 等とします。ただ s-maxage は CloudFront 側の設定との兼ね合いがある為、ここでは設定せず CloudFront 側のキャッシュ期間の設定に委ねた方が安全かもしれません。

l.145

class FormatError extends Error {}

Promise チェーン中で発生したエラーを区別する為のカスタムエラーです。

ローカル開発環境について

サンプルコードのリポジトリを見てもらえば分かると思いますが、特にフレームワーク等は使っていません。ただし機能の開発中にローカルでも実行できるようにはしてあります。オリジナルレスポンスはダミーの JSON で代替していますが、関数から S3 には実際にアクセスして画像を取得します。開発中はダミーの JSON の中身を適宜変更し、画像ファイルはテスト用の画像を S3 に置いてください。

ローカルの Node.js のバージョンは AWS 上の Lambda の実行環境と合わせて 6.10 としてください。またローカルから S3 へアクセスする為に、プロジェクトディレクトリの一つ上の階層に AWS SDK をインストールしておきます。

$ npm install aws-sdk

ローカルで関数を実行するにはコンソールで次のようにします。

$ npm run local-run

変換後の画像については base64 エンコードされた文字列がコンソールへ表示されます。この環境を拡張して画像を保存・表示するようにするとより良いかもしれません。

AWS の管理コンソールへアップロードする為のアーカイブ(***.zipファイル)を作成する場合は、コンソールで次のようにします。

$ npm run create-package

ローカル開発環境については下記のスライドにも書いたので、よろしければ参照ください。これは以前に私が 東京Node学園 でトークした際の資料です。Lambda@Edge 用でなく通常の Lambda 関数の開発について説明した資料ですが、14ページ以降のローカル環境についての記載は Lambda@Edge でも共通です。

関数のアップロードと動作確認

作成した関数を実際に AWS 上で動かすには、先の手順で作成した関数の枠組みを開き、アーカイブをアップロードします。今回のサンプルコードではメインロジック index.jssrc ディテクトリ配下に置いてあるので、「ハンドラ」には src/index.handler を指定します。

f:id:hkusu:20180524154940p:plain

また「メモリ」「タイムアウト」も適宜、変更しておきます。元画像の大きさによりますが、経験的にはメモリは 1024 MB、タイムアウトは数秒あれば十分そうですが、ここでは余裕をもってそれぞれ 2048 MB、15 秒を指定することにします。このあたりは元画像と画像変換の内容によるので適宜、調整してください。

f:id:hkusu:20180524154946p:plain

設定を保存したら、新しい「バージョン」を発行します。

Lambda 関数はコードと設定をひとまとめにして履歴管理できます。この操作は1つの履歴のバージョンとして保存するという意味です。

f:id:hkusu:20180524154951p:plain

バージョンが作成されたら、このバージョンのコードおよび設定を CloudFront と関連づけします。トリガーとして CloudFront を選び次のように設定を行います。

f:id:hkusu:20180524154955p:plain

f:id:hkusu:20180524155001p:plain

設定を保存し、CloudFront へ反映されるのを少し待った後、ブラウザで CloudFront のホスト + 画像の URL へ変換オプションのクエリ文字をつけてアクセスしてみます。指定したサイズの画像が表示されれば OK です。

HTTP/2 を有効にする為に、画像は HTTPS プロトコルで配信しましょう。

f:id:hkusu:20180524155006p:plain

おわりに

Lambda@Edge で画像をリアルタムに変換する仕組みについて紹介しました。今回のサンプルコードは画像のリサイズと WebP 形式への変換というシンプルなものでしたが、更に画像のフィルター加工(ぼかし等)や画像のクロップ(切り抜き)、また画像の合成等を実装してみると面白いかもしれません。

冒頭のとおりまだ実運用では試してないので、今後もし実際に運用する機会があったらそこで発生した問題や解決方法、知見をまた紹介したいと思います。また画像変換に関わらず Lambda@Edge を実際に運用してみた、などの事例がありましたら是非ブログ等で紹介いただければ幸いです。

参考にしたサイト