読者です 読者をやめる 読者になる 読者になる

MERY EC におけるセッション管理と JWT

Back-End 設計 Ruby

こんにちは。MERY のサーバーサイドエンジニアの @saidie です。

MERY では、女の子が知りたい最新の情報や欲しくなるファッションアイテムを、記事という形式を通して毎日届けています。 そして、ここ一年弱の間、ユーザが欲しいと思ったアイテムをそのまま手軽に MERY 上で買うことのできる、EC のシステム開発を進めてきました。

この記事では、MERY の EC システムを開発するにあたって、既存の MERY 会員のログインセッションと EC において必要とされる新たなセッションに関する設計の裏側と、セッション情報のやり取りに JWT (JSON Web Token) を使う取り組みについてご紹介したいと思います。

MERY の EC セッション

まず、EC システムを開発するにあたって、必要とされるセッションは以下の二つだと考えました。

  • カートセッション
    • ショッピングカートへの商品の追加/削除から注文まで。センシティブな情報をやり取りしないことと、途中で長く離脱したユーザが戻ってきた時にもカートにアクセスできるよう、有効期限は比較的長めにしたい。
  • ログインセッション
    • ユーザに関連した情報 (住所や過去の注文など) のやり取り。UX を損なわない範囲で有効期限はできるだけ短くしたい。

これらに加えて、MERY では記事を作成したり、気に入った画像やアイテムなどを LOVE して見返すことのできる MERY 会員のセッションが存在していました。 EC におけるログインセッションは MERY 会員のセッションを拡張することで代替できなくはなかったのですが、

  • 既存の MERY のデータと個人情報満載の EC データはインフラ構成的に厳密に分けておきたい
  • マイクロサービスの流れに乗って、EC 関連の情報へは API を通してアクセスしたい
  • devise にはもう近づきたくない

のような理由で、EC セッションは新たに実装する API が管理する形にしました。

f:id:sai-die:20160824025729p:plain

セッションの管理と JWT

さて、セッション管理の方法としてよく使われる方法は、以下のようなものだと思います。

  1. セッション開始時にランダムな文字列 (トークン) を生成して、ユーザやカートといったリソース(の識別子)と対にしてデータベースに格納する
  2. トークンを UA (User Agent; ブラウザなど) にセキュアな通信路で送信する
  3. UA はリソースリクエスト時にトークンを一緒に送る
  4. トークンをデータベースから引いてきて、リソースの識別子を取得する

このようにトークンを安全なデータベースに格納しておくで、UA から送られてきたトークンの正当性を確かめることができます。 また、仮にトークンが漏洩した場合など、データベースから該当のトークンを削除することで無効化することが出来ます。

一方、トークンにデジタル署名など特殊な細工を施すことで、トークンとなんらかの鍵だけでトークンを検証する方法もあります。 これを実現できるデジタル署名を使った方法が比較的最近 RFC になった JSON Web Signature (JWS; RFC7515) で、さらに暗号化も備えたものが JSON Web Encryption (JWE; RFC7516) です。 JWS はざっくり言うと、任意の JSON と署名を URL セーフな文字列にエンコードしてくっつけたもので、Web アプリ開発者にとって扱いやすいものになっています。JWE はさらに JSON の暗号化も行います。 この JWS/JWE で、二者間でやり取りする識別情報的なものを格納したものが JSON Web Token (JWT; RFC7519) であり、OAuth 2.0 の拡張仕様や OpenID Connect などで活用されています。 JWT では JSON オブジェクトのフィールド名がいくつか予約されています。例えば、

  • iss: JWT の発行者
  • sub: 識別される主体 (ユーザなど)
  • aud: JWT の宛先
  • exp: JWT の有効期限

などがあります。この JWT をセッショントークンとして用いると、以下のようにセッションに紐付いたリソースを特定できます。

  1. セッション開始時に sub にリソースの識別子を入れた JWT を生成する (サーバの秘密鍵で署名)
  2. JWT を UA にセキュアな通信路で送信する
  3. UA はリソースリクエスト時に JWT を一緒に送る
  4. サーバは公開鍵を使って JWT を検証した後、 sub を取り出す

また、必要に応じて iss, aud, exp などの検証もすると良いです。署名の検証に必要なのは公開鍵だけなので、生成したサーバ以外でも検証できることがシステムのアーキテクチャ次第では一つの利点となります。 ただし、JWT は基本的に手元にないので、無効にしたい場合は何かしらの対策を講じる必要があります。 有効期限を極端に短くする、JWT に ID を付与して無効化した ID 一覧を保存して比較する、JWT の発行日時が特定日時より古いものは無効とする、などが考えられます。

MERY EC の JWT

話を戻して、MERY では EC のセッションにはこの JWT (JWS) を使うことにしました。理由は主に以下のような感じです。

  • トークンをデータベース管理しなくてもよい
    • ただし、トークンを失効させるためにまた別の仕組みが必要なのでこれに関しては一長一短という感じ
  • トークンの検証のために API に問い合わせる必要がない
    • API リクエスト削減できて嬉しさがある
  • JSON には付加的な情報も含めることが出来るので、何かと便利
  • ダンでなんかかっこいい気がする!ブログネタになる

付加的な情報の一例として、カートに入っている商品の個数を JWT に埋め込んでいたりします。

f:id:sai-die:20160824124343p:plain

商品個数はサイトヘッダのカートアイコンのバッジを出すために使われており、MERY の大部分のページで表示されます。 都度 API に問い合わせずとも JWT の中身を見るだけで取得できるため、API へのリクエスト数を大幅に削減できています。

f:id:sai-die:20160824130228p:plain

具体的に Ruby で JWT を扱う場合は、jwt gem などを使うことで、JWT の生成や検証を簡単にすることができます。 ほぼ README のコピペですが、

require 'jwt'

private_key = OpenSSL::PKey::EC.new(File.read('/path/to/private_key'))
public_key = OpenSSL::PKey::EC.new(File.read('/path/to/public_key'))

payload = {
  exp: Time.now.to_i + 60 * 60 * 24,
  foo: 100,
  bar: {
    buzz: 'Hello, world!'
  }
}

# JWT のエンコード
token = JWT.encode(payload, private_key, 'ES512')
puts token
# eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzUxMiJ9.eyJleHAiOjE0NzIxOTIwNDgsImZvbyI6MTAwLCJiYXIiOnsiYnV6eiI6IkhlbGxvLCB3b3JsZCEifX0.AJkWXCSgLLe39MOZp8DjkbhrXZ3M-6lxpK0Ns7yGupHOF1qXP5dtcA6KQKVxdu8Z4-Aq5EhSSKq6uTtuQkkisDWLAduoa9xx06cGKMbwvPC7R4OEdS8O-D8AgYtKMGg7fsCaSLn76xtYw5W1AdsVMWirlT2WvAaawI5U6O1JroPd-MOw

# JWT の検証とデコード
payload, header = JWT.decode(token, public_key, true, { algorithm: 'ES512' })
puts payload
puts header
# {"exp"=>1472192048, "foo"=>100, "bar"=>{"buzz"=>"Hello, world!"}}
# {"typ"=>"JWT", "alg"=>"ES512"}

のように使うことができます。

まとめ

いろいろとぼかしたり端折ったりした部分もありますが、MERY EC におけるセッション管理は概ねこのような感じで行っています。 とりあえずこれまでのところ、JWT を使うことによる辛さはほとんど感じたことはありません。

データにデジタル署名をしてやり取りするというのは使い古された手法ではありますが、Web で扱いやすい形になっている JWT (というか JWS/JWE) には様々な応用可能性があるんじゃないかな、と思っています。

ペロリでは JWT を使い倒して新たな地平を切り開きたい方、MERY のセッション管理いけてないから作り直してやるぜという方、とにかく何でもいいから女の子にかわいいを届けていきたいという方のご応募をお待ちしています。 www.wantedly.com

クーポンの設計 ~MERYではこんなふうに作りました~

こんにちは、開発部のzooです。主にECサービス(market.mery.jp)の開発を担当しております。 今回の記事では、MERYのECサービスで運用しているクーポンの実装方法のアイデアを簡単に紹介いたします。

MERYのクーポン

ECサービスでは多くのお客様にお得にお買い物をしていただくために、この4月からクーポンを導入しました。 10%OFFになるとか、1000円OFFになるとか、ほとんどの方がお店やECサイトなど、どこかで使ったことがあるあのクーポンです。

f:id:zoopon:20160819004844p:plain:w750

MERYでクーポンを開発した際の機能に対する要求は、以下の項目を設定できることでした。

  • クーポンの付与対象者の条件
  • クーポン対象の商品
  • ◯◯円OFFまたは◯◯%OFFの値引き
  • 最低購入金額(この金額以上買ったらクーポンを使用できる)

etc...

また、クーポンの機能の他にも、

  • 商品一覧ページにおいてクーポン対象商品にクーポンラベルを表示する
  • 商品ページで値引き後の金額を表記する

ということなども必要になります。

いよいよ開発

こういう感じでは作りませんでした!

クーポンと聞くと、何かをトリガに値引きチケットをもらう、というようなことが想像できます。 例えば、ラーメン屋に行って会計時に次回以降に使用できる味玉券をもらう、のような感じです。 このイメージでクーポンを作った場合、例えば全ユーザーにクーポンを付与することを想定すると、次のような流れが考えられます。

  1. 管理画面等からユーザーにクーポンを付与(ユーザーとクーポンの紐付けテーブルにレコードを挿入)
  2. 非ログインユーザーがログイン
  3. ログインユーザーが商品ページにアクセス
    1. ログインユーザーに紐づくつクーポンを取得
    2. 該当ページの商品にクーポンを適用できるか判定
    3. 適用できる場合は値引き額等を表示する

この例では、管理画面でのクーポン付与操作がクーポン付与のトリガですが、ユーザー登録の完了や購入の完了をトリガにするなどして、クーポンを絡めたいろいろな施策を行うことができます。

一方で、

  • 多くのクーポンは使用されることがないため無駄なデータが大量にできてしまう
  • ユーザーとクーポンの紐付けレコードの肥大化に伴いパフォーマンスが劣化する

というようなマイナス要素もあるかと思います。

いずれにせよ、僕自身、過去にこのような思想でクーポンを開発したこともあるのですが、今回はこういう感じでは作りませんでした。

いつ付与するの?最後でしょ!

クーポンをユーザーに付与するタイミングは検討すべき大きなポイントの1つでした。

機能以外の要求として、詳細は省きますが、経理処理の手続きを考慮すると、月をまたぐクーポンを付与したくない、ということがありました。 これは例えば、全ユーザーを対象としたクーポンキャンペーンを定常的に行いたい、という場合に、1日の0:00から月末の23:59:59まで使用できるクーポンをその期間内に付与する必要があるということになります。

では、1日の0:00のタイミングで対象となるユーザーにクーポンを付与すればよいかというと、非常に多くのユーザーがいるMERYでは、対象者全員に付与するには時間がかかりすぎてしまいます。 また、このようにした場合、そのタイミング以降にユーザー登録をしたユーザーへの付与をどうするか、ということも考える必要があります。

これを避けるために、ユーザーがログインしたタイミングで付与するというようなことも考えられますが、既にログインしている場合には付与できません。 そうであるならば、アクセスしたタイミングで付与していなければ付与する、というようにすれば解決する気もしますが、処理が煩雑になったり、無駄な処理が増えそうです。

そこで、付与するタイミングは最後の最後、購入直前の確認画面に到達したタイミングになりました。

あれ、最後だと...こう作りました!

クーポンが付与されるタイミングは最後ですが、ユーザーがサイトにアクセスしたタイミングから、クーポンのラベル表示やクーポン使用時の値引き額を表示する必要があります。 また、その際には付与対象者であるユーザーかどうか、クーポン対象の商品であるかどうかということを考慮して表示の出し分けをする必要があります。

そこで、次のような流れで処理することになりました。

  • ユーザーが商品ページにアクセス
    1. ユーザー情報と商品情報から使用可能なクーポンを取得
    2. 取得したクーポンから既にユーザーが使用したクーポンを除外
    3. 残ったクーポンがユーザーが使用可能なクーポン

また、クーポンに関連するクラスのイメージはこのようになります。

f:id:zoopon:20160819011938p:plain:w400

上記の言い換えになりますが

  1. ユーザーがユーザーセグメントに含まれ、かつ商品が商品セグメントに含まれるクーポンを取得
  2. 取得したクーポンに消費したクーポンが含まれる場合は除外
  3. 残ったクーポンがユーザーが使用可能なクーポン

となります。 このようにして、使用可能なクーポンを取得し、表示の出し分けや値引き額の計算を行うようにしました。

また、このようにしたことで、『こういう感じでは作りませんでした!』で述べたマイナス要素

  • 多くのクーポンは使用されることがないため無駄なデータが大量にできてしまう
  • ユーザーとクーポンの紐付けレコードの肥大化に伴いパフォーマンスが劣化する

というようなことも緩和できていると思います。

こうして、MERYのクーポンができあがりました。

まとめ

MERYで実際に運用しているクーポンの実装方法のアイデアを紹介させていただきました。 今回は購入までで考慮した点を紹介しましたが、購入した後にも考慮することがあったりします。 あのサイトのクーポンはどうなっているんだろう、このサイトのクーポンはどうなっているんだろう、と思いを馳せていただくきっかけになればと思います。

また、MERYのクーポンは今後もパワーアップされていくはずです。 ぜひMERYでクーポンを使ってお得にお買い物をしてください。

それでは、みなさま、ごきげんよう!

アプリも Web もキレイに楽しく♪ MERY の自動再生動画のコツ

Ruby on Rails 動画

こんにちは。ペロリ 開発部の池袋です。

今回は Ruby on Rails における自動再生動画のアップロード周りの話をします。
MERY ではアプリ・Web どちらも動画に対応しており色々なところで動画を使っています。
動画に対応することでコンテンツをよりリッチにユーザに届けることを目指しています。

例)《読者プレゼント》スキマ時間でもっと可愛く!MERYのオススメ動画をチェック

MERY の自動再生動画の概要

アプリと Web(PC)では S3 に置いた動画をそのまま自動再生で流していますが、Web(スマホ) では動画を分割した画像を js でコマ送りにして表示しています。
Rails には video_tag という video タグを生成してくれるメソッドがあり、一般的にはこのメソッドを使って動画を再生するのですが、 Web(スマホ)で再生しようとすると OS のプレイヤーが立ち上がってしまいインラインでの自動再生ができないため上のようなやり方をしています。

インラインでの自動再生には他にも GIF アニメーションや、video タグのシークバーを毎フレームずらしながら内容を canvas に描画するといった方法があります。
しかし、前者には適当な画質を保とうとするとファイルサイズが大きくなりすぎてしまう問題があり、後者には手元の Android で動かない(かつ CPU を大量に使う)という問題があったため上記のやり方を採用しました。

そのため動画登録は動画ファイルを S3 に置くだけではなく、動画を画像に分割して S3 にアップロードする処理も加わります。
さらに、動画が読み込まれるまでの間に表示しておく画像として動画のサムネイルも保存しておく必要があります。

以下、動画登録の流れに沿ってそれぞれどのように処理しているか説明していきます。

動画登録の流れ

Web(スマホ)用に動画を画像分割

アップロードされた動画はまず画像に分割されます。 画像の分割処理には jani-strip-maker を使っています。
動画の再生に使用する画像は動画を一コマずつではなく、複数のコマをくっつけて一枚の画像にしています。 複数コマで一枚にするのは画像の圧縮効率をあげるためとリクエスト数を減らすためです。
この gem では ffmpeg を使用して動画をコマごとの画像に変換し RMagick を使って複数のコマを一つの画像にしています。

f:id:peroli_dev:20160805151155p:plain

使い方

以下のように書くだけで動画の画像分割とその合成まで行ってくれます。

options        = Jani::StripMaker::TranscodeOptions.new
options.fps    = 15
options.width  = 300
options.height = 200

Jani::StripMaker::Movie.new(
  movie_filepath:    "#{動画ファイルへのパス}",
  transcode_options: options
).to_strips.each(&:write)

ただし、この gem は動画を画像に分割する際に利用する ffmpeg のコマンドのオプションが決め打ちになっています。
渡せるオプジョンは fps, width, height の三つです。 これら三つのオプションは必ず渡す必要がありますし、これら以外のオプションを渡すことはできません。
さらに bitrate のオプションは 50000k で gem にハードコードされています。

メンテナンスがあまりされていないこともあり README の通りに書いても動かないといった問題などもあるのですが、全体のコード量が少ないためいざという時はパッチを当てればいいだろうということで導入しました。

ちなみに分割された画像をコマ送りに再生するのは mobile-videoplayer.js を使っています。

f:id:peroli_dev:20160805170313g:plain

動画ファイルを S3 にアップロード

動画ファイルのアップロードには MERY で以前から画像のアップロードに使用している Paperclip を使っています。
画像のときと同じように Model に以下のように書くと動画とさらにサムネイルもアップロードすることができます。

class Movie < ActiveRecord::Base
  has_attached_file :video,
    styles: {
     thumb: { format: :jpg },
    }
  
  validates_attachment_content_type :video,
    content_type: ['video/mp4']
end

ちなみにアップロードされた動画の URL は movie.video.url のように書くと取得できます。サムネイルに関しては movide.video.url('thumb') です。

また、動画の保存時にフォーマット変換を行っています。
iOS で動画を再生するために H264 のプロファイルを Baseline の Level 3.1 に指定し(参考:iOS Developer Library )、 プログレッシブダウンロード再生をするためにメタ情報をデータの前に持って行っています。
このフォーマットの変換には paperclip-av-transcoder を使っていて、
下のように styles と並列に指定することで ffmpeg で動画フォーマットを変換する際にオプションとしてパラメータを渡すことができます。

class Movie < ActiveRecord::Base
  has_attached_file :video,
    styles: {
      thumb: { format: :jpg },
    },
    mobile: {
      format: 'mp4',
      convert_options: {
        output: {
          :'c:v'       => 'libx264',
          :'profile:v' => 'baseline',
          :'level:v'   => '3.1',
          :movflags    => 'faststart',
        }
      }
    },
    processors: [:transcoder],

ただし、paperclip-av-transcoder を使うとデフォルトでは 3s の1フレーム目を取得するようになってしまいます。(ソースのデフォルト値を設定している箇所
そのため動画の最初の画像をサムネイルに使いたい場合は styles の中で time0 に指定してあげる必要があります。

styles: {
  thumb: {
    format: :jpg,
    time: 0
  },
}

これで自動再生に必要な動画とそれを分割した画像を保存することができました。

まとめ

Rails で自動再生動画をアップロードする際の基本的なやり方や気をつけるべき点などを書きました。参考にしていただければと思います。

ペロリではエンジニアを募集しています。ご応募お待ちしております

www.wantedly.com

© peroli, Inc.