メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

ActiveModelSerializersを使った所感

こんにちは。メドピアエンジニアの保立です。

メドピアでは、ドクター向けに運営している「MedPeer」のiOSアプリを3月15日にリリース致しました!!!
今回は、iOSアプリ開発の過程で、APIの実装にActiveModelSerializersを使ったので、そこで得た知見を書きます。

ActiveModelSerializers を使った理由

json形式のレスポンスを返却する場合、jbuilderを使うケースも多いのではないでしょうか。
メドピアでも、今まで外部へのAPIにはjbuilderを使用していました。
しかし、iOSアプリ用のAPIでは、以下のメリットを考慮して、ActiveModelSerializersを使うことにしました。

メリット① 複雑なjsonを返す際に、ActiveModelSerializersの方が、レスポンスが早い

スマートフォン用のAPIでは、なるべく少ないリクエストで画面の描画に必要なすべての情報を返却したいため、何重にも入れ子になるjsonを返却することが多いと思います。そのため、viewをレンダリングするjbuilderでは汎用的なオブジェクトはpartial化することが求められますが、partialを呼び出すのに時間がかかってしまいます。
一方、ActiveModelSerializersでは、レスポンスの内容を定義するserializerにhas_manyhas_oneを使って、関連するオブジェクトを指定することができます。これにより、jbuilderよりも素早くレスポンスを返すことができます。

メリット② DSLな書き方をしなくてよい

jbuilderの書き方は、直感的に分かりづらい記法になるケースがあります。

json.title  @post.title
json.body @post.body
# {"title": "タイトル", "body": "本文"}


json.post @post, :title, :body
# {"post": {"title": "タイトル", "body": "本文"}}}

# これも同じ内容を返す(こっちの方が少し分かりやすい)
json.post do
  json.title @article.title
  json.body @article.body
end

さらに、partialを読んだり、if文などの分岐が入ると、より分かりづらくなります。
ActiveModelSerializersでは、Rubyの記法に則って書けるため、分かりやすいコードになります。

以上の点から、メドピアのiOSアプリ用APIとして、ActiveModelSerializersを採用しました。

使用例

ActiveModelSerializerは以下のように使用します。

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
    render json: @post, serializer: PostSerializer
  end
end


class PostSerializer < ActiveModel::Serializer
  attribute :title

  # 別名をつけたい時はkeyを使用する
  attribute :created_at, key: :timestamp

  # has_manyやhas_oneで関連するオブジェクトを指定する
  has_many :comments,  serializer: CommentSerializer

  # メソッドを呼び出すことも可能
  attribute :published

  def published
    object.published_at.present?
  end  
end

class CommentSerializer < ActiveModel::Serializer
  attribute :body
end

# レスポンス
{  
  title: "タイトル",
  timestamp: "2018-01-01T00:00:00+09:00",
  published: true,
  comments: [
    {
       body: "コメント" 
    }  
  ]
}



並列関係の複数Modelに紐づくAPIを返却したい場合もあります。 以下の例は、アプリ起動時など、Masterデータを取得する時のコードです。

class MasterDataController
  render json: MasterData.new, serializer: MasterDataSerializer
end


class MasterData
  def first_master_data
    @first_master_data ||= Master::FirstMasterData.all
  end

  def second_master_data
    @second_master_data ||= Master::SecondMasterData.all
  end
end


class MasterDataSerializer
  has_many :first_master_data
  has_many :second_master_data

  class FirstMasterDataSerializer
    attributes :id, :name
  end

  class SecondMasterDataSerializer
    attributes :id, :name
  end
end

使ってみた感想

書いていて、ものすごくつまずくということがなかったです。
ActiveRecordの延長のような作りなので、つまずくことなく書くことができました。
またGitHubのドキュメントが豊富なので、基本的にわからないことがあってもドキュメントをみれば解決しました。
コード量はjbuilderより多くなるので、「すぐに実装したい」「複雑なjsonを返す必要がない」場合はjbuilderでもいいと思います。 逆に、長い間運用したり、拡張する可能性があるAPIを用意する場合は、ActiveModelSerializerおすすめです^^

おまけ(Active Model Serializer と jbuilder のパフォーマンス比較)

posts#showアクションにて、postsを1件とhas_many関係のcommentsを20件をレスポンスに設定するケースで比較します。

jbuilder

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id]).preload(:comments)
  end
end

# app/views/posts/show.json.jbuilder
json.title  @post.title
json.page_views do
  json.partial! partial: 'comment', collection: @post.comments, as: :comment
end

# app/views/posts/_comments.json.jbuilder
json.body comments.body

実行結果は以下のようになります。

Processing by PostsController#show as JSON
Posts Load (1.0ms)  SELECT `posts`.* FROM `posts` LEFT JOIN `comments` ON `posts`.`id` = `comments`.`post_id` 
Rendered posts/_comments.jbuilder (0.3ms)
Rendered posts/_comments.jbuilder (0.3ms)
Rendered posts/_comments.jbuilder (0.3ms)
(commentsの数だけ続く)
Rendered posts/_comments.jbuilder (0.3ms)
Rendered posts/show.json.jbuilder (47.2ms)
Completed 200 OK in 87ms (Views: 49.6ms | ActiveRecord: 7.5ms)

Active Model Serializers

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id]).preload(:comments)
    render json: @post, serializer: PostSerializer
  end
end

# app/serializers/posts_serializer.rb
class PostSerializer < ActiveModel::Serializer
  attribute :title
  # has_manyやhas_oneで関連するオブジェクトを指定する
  has_many :comments,  serializer: CommentSerializer
end

class CommentSerializer < ActiveModel::Serializer
  attribute :body
end

実行結果は以下のようになります。

Processing by PostsController#show as HTML
Posts Load (1.0ms)  SELECT `posts`.* FROM `posts` LEFT JOIN `comments` ON `posts`.`id` = `comments`.`post_id` 
[active_model_serializers] Rendered PostSerializer with ActiveModelSerializers::Adapter::Attributes (11.23ms)
Completed 200 OK in 41ms (Views: 9.7ms | ActiveRecord: 7.9ms)

と、いうことで2倍以上の速度でレスポンスを返すことができました。
実際のMedPeerアプリでは、3重4重に入れ子にしたjsonを返却することがあるため、Active Model Serializers の恩恵は計り知れないものがあります。


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

社屋を移転したのでオフィスの壁をデザインした話

f:id:umeccco:20180404142238j:plain

こんにちは。デザイナーの松村です。

この春、メドピアはめでたくオフィスを銀座に移転しました!ワーワー! medpeer.co.jp

今回はオフィス移転をきっかけに会社の沿革をイラストに起こしたやり取りが「ザ・メドピアのものづくり」という感じだったので、デザイナー活動記としてブログにしたためてみました。

ラフを作ろう

広報の藤野女史から、WALLコンセプトの共有を受けラフ案を作成するながれに。 MedPeerの転換期には必ず医療に関する事件や法律改正が絡んでいるので、MedPeerのこれまでの歩みとの相関関係を見せたい、というのがコンセプトでした。

コンセプト時点では「MedPeerの歴史」がフィーチャーされていたのですが、「医師をサポートし、患者を救う」というMedPeerコンセプトから考えても、いっそMedPeerだけじゃなくてもっと広いレンジにしたほうがハマるのではないか、と考えて作ったのがこちらのラフ案。

f:id:umeccco:20180129144708j:plain f:id:umeccco:20180130123132j:plain
f:id:umeccco:20180130123104j:plain f:id:umeccco:20180130123115j:plain

いかがですかこのやる気を引き出してくれる誉め殺しコミュニケーション。

いいもの作って期待に答えるしかない!!て感じですよね。

〜ちなみにメドベアとは、MedPeer内で連載中の4コマ漫画です。〜

医師の声を取り入れよう

ラフ案のプレビューで、代表医師の石見先生に「上医は国を医し、中医は人を医し、下医は病を医す」という言葉に影響を受けたのでそれを入れたい、というフィードバックを受けました。

こうなってくるとせっかくなので医師会員の皆様の声も入れたいなあ……ということになり、 石見代表にMedPeer内の 「FORUM Q&A Life」で募集してもらいました。

f:id:umeccco:20180129144542j:plain

ワクワクしながら投稿を待ちわびる我々。

f:id:umeccco:20180308132217j:plain

おかげさまで、最終的には70以上の案をお出し頂きました。 これには石見代表も感激。

f:id:umeccco:20180308132156j:plain

いよいよ完成!

よーしいよいよ入稿だ〜!と思ったら大きな罠が。 f:id:umeccco:20180130125550p:plain

f:id:umeccco:20180129144427j:plain

アイレベル設定を完全に失念していました。

こんな時にもワハハノリで流してくれるので変にストレスを溜めずに修正することができます。

f:id:umeccco:20180130121903p:plain

というわけで最終的に調整して完成!

f:id:umeccco:20180404142415j:plain

対面には石見セレクションの医学書が。

f:id:umeccco:20180404142335j:plain

こんな感じで弊社では、部署や役職の垣根なく意見を交換してものづくりを行なっています。 (今回のように社内外の医師にご意見を頂き、一緒に作り上げることもしばしばあります。)

MedPeerのデザイン

今回は弊社の思想を表現するため、よりコンセプチュアルなデザインとなりましたが、 webサイトやイベントのデザインも「医師の体温」が伝わるデザインに、と調整を行なっています。

MedPeerのサイトコンセプトはズバリ「臨床の役に立つ」サイト。 今年からはそれに加えて、臨床知識を得られながらも、患者さんに向き合うための英気を養える、医師にとって居心地の良い場の再構築を目指しております。

「自分ならこういうことで実現するかなあ」と思った皆さま。

是非一度、お話をお聞かせください。

MedPeerでは共に働く仲間を募集しています。

おまけ

今回は自動販売機もラッピングできるということで、欲望溢れるデザインにしてみました。(ヘルステックカンパニーの意識とは…)

f:id:umeccco:20180404143127j:plain

MedPeerの会議室 Patioはイベントスペースとしても貸し出しております。 勉強会などの会場でお困りの方はお気軽にお声かけください。

f:id:umeccco:20180404143225j:plain

スカイツリーも見えますよ〜

f:id:umeccco:20180404143149j:plain

メドピアではIT勉強会での会場を提供いたします

こんにちは。メドピアCTOの福村です。
RubyKaigi2018@仙台が横浜開港祭と被っていて家族との調整で頭を抱えている今日このごろです。
開発合宿の計画も進めており今年も(すでに4月ですが)熱い1年になりそうです。
好きな季節は夏です。

さて、メドピアグループ(メドピア、フィッツプラス、Mediplat)は、2月26日から拠点を銀座に移し1フロアでグループシナジーを生みながら事業に邁進しております。 medpeer.co.jp

今回の移転でイベントスペースを作ったのでIT業界への貢献の思いも込め、 ITや医療系の勉強会に積極的にこのスペースを提供していこうという運びになりました。

イベントスペース

https://s3-ap-northeast-1.amazonaws.com/prod.cojp.wp.media.press/press/wp-content/uploads/2018/04/03152607/patio.jpg

アクセス

〒104-0061 東京都中央区銀座6-18-2 野村不動産銀座ビル11階
東銀座駅 徒歩3分

収容人数

50名

設備

  • プロジェクター
  • ゲストWi-Fi
  • 椅子・机
  • マイク
  • 電源タップ

お問い合わせ先

info@medpeer.co.jp

  • イベントの概要
  • ご希望の日時(社員立ち会いが必要な都合上、平日のみとさせていただいております)
  • 参加予定人数

※ 営利を目的とした勉強会については、お断りをさせていただくことがございます

勉強会開催します!

JapanTaxiさんと4/25(水)にRubyをテーマに勉強会を実施します!
Ruby/Rails開発全般で面白いテーマが揃っていると思います。 是非是非新オフィスに遊びに来てください。

medpeer.connpass.com

会場を提供した勉強会

移転して1ヶ月くらいですがいろいろなIT系の勉強会を開催しております!

savanna.connpass.com

ginzarb.doorkeeper.jp

medpeer.connpass.com

medpeer.connpass.com

銀座付近で勉強会したいなぁと思った際はぜひともお気軽にお声がけください。

※ 追記(2018/11/27)設備に「マイク」と「電源タップ」を追加しました。


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

トピック型のモバイルPush通知をRails + Amazon SNSで実装する

こんにちは。メドピアにWebエンジニアで入社して約6ヶ月の佐藤です。

メドピアは2/26から銀座に移転しました。 銀座に移転しても花粉からは逃げられませんでしたが 、移転後はなぜか空気清浄機が増えて助かっています。

書いてある事

Amazon SNSの複数ユーザーに一度に通知を送る「トピック」を使ってRailsでPush通知を実装した際の処理フローを主に書いてあります。

f:id:motsat:20180313115508p:plain:w500

トピック機能を使わず、Amazon SNSのプッシュ通知のみを実装した場合はわりとシンプルですが、

  • トピック購読(subscribe)
  • トピック購読の解除(unsubscribe)

等のトピック関連の処理が加わってくると状態管理が複雑になってきます。

また、

  • トピック
  • エンドポイント
  • ARN(Amazon Resource Name)

などAmazon SNSやAWS上での用語も理解する必要があるため、その説明も簡単に入れました。

今後Amazon SNSを使ってpush通知(とトピック機能)を実装する上で何かしら参考になれば幸いです。

また、ブログ内のサンプルコードは下記のリポジトリに格納してあります。

サンプルコード

ブログ内は処理の抽象化された部分が書かれているためaws_sdkの利用部分やDBへの反映部分は出てきませんが、 リポジトリ内の他ファイルにはそこも含めたものが上げてあります。

(ただ、弊社事情部分を削ってあるのでそのままでは動かない参考的なものです)

Amazon SNSのモバイルプッシュ通知

いろいろなデバイス(プラットフォーム)に、送信側はそれほど処理を変えずに簡単にプッシュ通知できるサービスです。

他にもこんな特徴があります。

  • 送信可否状態をAmazon SNS側で管理

 プッシュ通知の送信が失敗すると、状態変更(後述)されるまで送信しない

  • 個別の送信先、またトピック機能を使う事で複数の送信先にも一度に送信できる

 詳細  https://docs.aws.amazon.com/ja_jp/sns/latest/dg/SNSMobilePush.html

Amazon SNSのトピック

トピックは、日本語訳で「話題」や「論題」です。

Amazon SNSのトピック機能は、事前にトピック(Topic)を作成しておき、 ユーザーはそのトピックを購読(subscribe)する事でそのトピックへの通知を受け取ることが出来るようになります。

複数ユーザー(アプリ)を紐付けでおけば、APIの利用側はそのトピックに対して送った通知メッセージが複数ユーザーに届くようになります。

大量ユーザーへのPush通知も、トピックに紐付ければ1回の送信で行えるという事です。

https://docs.aws.amazon.com/ja_jp/sns/latest/dg/CreateTopic.html

アプリ側の機能概要

今回実装したアプリの、ユーザーから見たトピックの選択イメージはこんな感じです。

  • 複数プラットフォーム(iOS+Android)でプッシュ通知を受け取れる
  • ログアウト時にはpush通知が来ないようになる
  • トピックごとに通知のON/OFFができる

アプリ側の設定画面の雰囲気です。

f:id:motsat:20180312205655p:plain

Amazon SNS側作業

AWS マネジメントコンソールで事前に下記の作業を行います。 (APIのユーザー管理やアクセス権限周りも必要あれば設定)

  • アプリケーション作成
  • トピック作成

これらはAPIで作成することもできますが、今回は事前に作成する形にしたのでAWS マネジメントコンソールでの作業を前提とします。

アプリケーション作成

プラットフォームと対になるものです。 iOS、Android分であれば計2つを作成します。

必要なもの

  • iOSのp12ファイルとそのパスワード (Androidは試していませんが、GCMアクセスキーが必要となるはずです)


下記はiOSのアプリケーション作成画面です。

f:id:motsat:20180311214507p:plain

  • アプリケーション名
  • iOSの場合はDevelopmentまたはProductionの選択
  • p12ファイルパスワードを入力

です。(証明書はp12ファイルから勝手に入力されます)

これで、アプリケーションの「ARN」である、「application_arn」が生成されます。(APIでの送信時に必要)

ARN(Amazon Resource Name)は、AWS上に何かリソースを作成した時に与えられる名前です。

この先に出てくる他のリソース(トピック、エンドポイント等)にもARNが設定され、 同じくAPIでそのリソースを指定する時などに使います。

トピック作成

トピックA、トピックBであれば計2つです。 iOS + Androidの2つのアプリケーションがある場合でも、通知単位を分けないのであればトピックは同じ物を利用します。 (逆に、iOSとAndroidで通知単位を分けたければ別に作ります)

f:id:motsat:20180311214515p:plain

これで、トピックの「ARN」である、「topic_arn」が生成されます。(APIでの送信時に必要)

Rails側のモデルのイメージ

今回の実装例に出てくるモデル(DB)については、下記のようなイメージです。

f:id:motsat:20180313113150p:plain

topic_1_…などtopicごとにカラムを持っていますが、topicごとにレコードを持つ形なども良いと思います。

また、実際にはプッシュ通知のメッセージを格納しているモデルなどもいろいろあるのですが、説明用に省きます(実際のものはリポジトリをご覧下さい)

user_push_notification_settings

アプリ(ユーザー)の通知許可フラグを持つテーブルです。 ユーザーに対して1レコード持つ形です。iOS側で反映したらAndroid側も同じ設定となります。

  • user_id

 ユーザーID

  • topic_1_enabled

 トピック1が有効かどうか

  • topic_2_enabled

 トピック2が有効かどうか

user_push_notification_tokens

アプリ(ユーザー)のデバイストークンと、それに関連付けられるARN(Amazon Resource Name)を保存するテーブル。 ユーザーに対し、アプリを利用するプラットフォーム分のレコードを持ちます。

下記の属性を持つイメージです。

  • user_id

 ユーザーID

  • mobile_platform

 プラットフォーム(ios or android)の指定

  • device_token

 アプリから取得したトークン

  • endpoint_arn

 Amazon SNSのendpoint作成時に取得するARN(Amazon Resource Name)

  • topic_1_subscription_arn

 Amazon SNSのトピック1をsubscribeした時に取得するARN(Amazon Resource Name)

アプリサーバー(Rails)の実装

Amazon SNSのモバイルPushとトピック通知を使うためにRails側の実装部分です。

  • アプリからトークンを取得
  • 取得したトークンをAmazon SNSのエンドポイントに紐付ける
  • そのエンドポイントをトピックに紐付ける
  • また、不要になったらエンドポイントやトピックとの紐付けを削除する

という処理です。 主に下記リクエスト時の処理が必要になります。

  • トークンの新規保存、更新時
  • トークンの破棄(ログアウト)時
  • トピックの購読ON/OFF切り替え時

トークン(token)の変更時

f:id:motsat:20180311213737p:plain

1. アプリからtokenを取得

まずはアプリからtokenを取得し、Railsサーバーにリクエストします。

以下、実コードはAmazon SNSへのリクエスト処理はsidekiqで非同期処理で行っていますが、説明上Railsサーバーとしています

endpoint未作成? => YES の時

2. Amazon SNSのcreate_endpointを実行

まずはAmazon SNS側にendpointを作成します。

作成時に、そのendpointを表す 「endpoint_arn」がレスポンスに含まれます。

endpoint_arnはpush通知やトピックの紐付け(subscribe)を実行する時に必要になるのでDB等に保存します。

3. Amazon SNSのsubscribeを実行

2.で取得したendpoint_arnに、トピックの紐付け(subscribe)を行います。

subscribeのレスポンスには、「subscription_arn」が含まれます。 これは、トピックへの送信時には必要ありませんがunsubscribe時に必要になるのでDB等に保存します。

また、一度に複数トピックをsubscribeするAPIは無いため、トピック分subscribeを行う必要があります。

endpoint未作成? => NO の時

2.Amazon SNSのset_endpoint_attributesを実行

すでに作成されたendpoint内の属性を更新する、Amazon SNS のset_endpoint_attributesを実行します。 下記のパラメータを更新します。

  • Token

 アプリから送信された最新のtokenに上書き保存します。

  • Enabled

 送信可否状態です。trueまたはfalseです。  endpoint作成時の初期値はtrueですが、アプリへのPush通知が失敗すると自動的にfalseに更新されます。

アプリがPush受信可能な状態だと信じてtrueを設定します(アプリから送信された最新のtokenなので)。

サンプルコードです。 (メソッド化されているので、Amazon SNS APIの実行や具体的な処理の詳細はリポジトリをご覧ください)

  # トークン(token)の変更時
  def on_updated_token(user_push_notification_token)
    has_device_token = user_push_notification_token.device_token.present?
    has_endpoint_arn = user_push_notification_token.endpoint_arn.present?

    if has_endpoint_arn
      if has_device_token
        set_endpoint_attributes(user_push_notification_token) # b-2.Amazon SNSのset_endpoint_attributesを実行
      else
        delete_endpoint(user_push_notification_token)
        unsubscribe_all_topics([user_push_notification_token])
      end
    else
      return unless has_device_token
      create_platform_endpoint(user_push_notification_token) # a-2. Amazon SNSのcreate_endpointを実行

      on_updated_setting(user_push_notification_token.user) # a-3. Amazon SNSのsubscribeを実行(後述のトピック通知設定の時と同じ処理)
    end
  end

private

  def set_endpoint_attributes(user_push_notification_token)
     requester = AwsSnsRequester.new
     requester.set_endpoint_attributes(user_push_notification_token.endpoint_arn,
                                       user_push_notification_token.device_token)
  end

  def create_platform_endpoint(user_push_notification_token)
    requester = AwsSnsRequester.new
    response = requester.create_platform_endpoint(user_push_notification_token.user_id,
                                                  user_push_notification_token.device_token,
                                                  user_push_notification_token.mobile_platform.value)
    user_push_notification_token.endpoint_arn = response.endpoint_arn
    user_push_notification_token.save
  end

  def on_updated_setting(user)
    user_push_notification_setting = user.user_push_notification_setting
    UserPushNotificationSetting::SUBSCRIBE_TOPICS.each do |topic|
      if user_push_notification_setting.enabled_by(topic)
        subscribe_topics(topic, user.user_push_notification_tokens)
      else
        unsubscribe_topics(topic, user.user_push_notification_tokens)
      end
    end
  end

トークンの破棄(ログアウト等)時

f:id:motsat:20180311214119p:plain

1.アプリからtoken削除リクエストを送信

アプリからのログアウト時など、Railsサーバーに削除リクエストを送信します。

2.Amazon SNSのdelete_endpointを実行

作成済みのAmazon SNS上のendpointを削除します。 DB上に保存されたendpoint_arnも同時に削除します。

3.Amazon SNSのunsubscribe を実行

delete_endpointでendpointを削除しても、自動でそれに関連付けされたsubscription_arnも削除されるわけではありません(自動でやってほしい…)

なので、subscribeをしていた場合には、unsubscribeをしておく必要があります。

ドキュメントにも注意書きがあります。

https://docs.aws.amazon.com/ja_jp/sns/latest/api/API_DeleteEndpoint.html

When you delete an endpoint that is also subscribed to a topic, then you must also unsubscribe the endpoint from the topic

DB上に保存されたsubscription_arnも同時に削除します。

実装を一部抜粋してコメントを追記したものです。(メソッド化されているので、Amazon SNS APIの実行や具体的な処理の詳細はリポジトリをご覧ください)

※ トークンの更新時とコードと同じです。delete_endpointが追加されています。

  def on_updated_token(user_push_notification_token)
    has_device_token = user_push_notification_token.device_token.present?
    has_endpoint_arn = user_push_notification_token.endpoint_arn.present?

    if has_endpoint_arn
      if has_device_token
        refresh_attributes(user_push_notification_token) 
      else
        delete_endpoint(user_push_notification_token) # 2.Amazon SNSのdelete_endpointを実行
        unsubscribe_all_topics([user_push_notification_token]) # 3.Amazon SNSのunsubscribe を実行
      end
    else
      return unless has_device_token
      create_platform_endpoint(user_push_notification_token)

      on_updated_setting(user_push_notification_token.user)
    end
  end

private
  def delete_endpoint(user_push_notification_token)
    AwsSnsRequester.new.delete_endpoint(user_push_notification_token.endpoint_arn)
    user_push_notification_token.endpoint_arn = ""
    user_push_notification_token.save
  end

トピックの購読ON/OFF切り替え時

トピック購読の切り替えはかなり単純です。 購読状態を見て、subscribeまたはunsubscribeするだけです。

f:id:motsat:20180311214133p:plain

1.アプリからトピックの選択状態を送信

トピックごとにtrue/falseなど、subscribeまたはunsubscribeするための状態を送信します。

2.Amazon SNSのsubscribe/unsubscribeを実行

アプリから送信されたトピックの選択状態に合わせ、Amazon SNSのsubscribeまたはunsubscribeを実行します。

DB上に保存されたsubscription_arnにも同時に反映します。

サンプルコードです。 (メソッド化されているので、Amazon SNS APIの実行や具体的な処理の詳細はリポジトリをご覧ください)

※トークンの更新時に行うコードと同じものです。

  def on_updated_setting(user)
    user_push_notification_setting = user.user_push_notification_setting
    UserPushNotificationSetting::SUBSCRIBE_TOPICS.each do |topic|
      # 2.Amazon SNSのsubscribe/unsubscribeを実行
      if user_push_notification_setting.enabled_by(topic)
        subscribe_topics(topic, user.user_push_notification_tokens)
      else
        unsubscribe_topics(topic, user.user_push_notification_tokens)
      end
    end
  end

その他

テスト(RSpecなど)の実装はClientStubsが便利

外部APIが絡むテストを実行する場合、スタブやモックなどが必要になる事も多いですが、aws_sdkには標準でスタブに関する機能があります。 これを使う事で、テストの実装もかなり楽になりました。 https://docs.aws.amazon.com/sdkforruby/api/Aws/ClientStubs.html

例えば、RSpecでのテスト実行時は全ての処理をスタブとするのであれば、spec/rails_helper.rb等に下記のように記述します。

# テスト実行時、aws_sdkは全体的にスタブにする
if Rails.env.test?
  Aws.config[:stub_responses] = true
end

aws_sdkでテスト時のレスポンスなどを任意のものにする

また、テスト時に場合によってはレスポンス内容を変えたくなったりする事もあると思います。 その場合は、下記のように一部のレスポンスを指定する事ができます。

aws_sdkのcreate_platform_endpointを実行した時にendpoint_arnが「new_endpoint_arn」としてレスポンスされるようにしたものです。

sns_response = Aws::SNS::Types::CreateEndpointResponse.new(endpoint_arn: "new_endpoint_arn")
Aws.config[:sns][:stub_responses][:create_platform_endpoint] = sns_response

まとめ

実装面やその他ふくめて、良かった所/つらかった所です。

良かった所

  • 大量配信の負荷をあまり気にしなくて良い

 トピック機能を使った場合は、大量配信時にもAPIを使う側は1件送信するだけ。

  • 事前準備が楽

 Amazon SNS側のアプリケーションの設定やトピックの設定も、シンプルで設定しやすく特に迷うこともありませんでした。

  • 料金安い

 Amazon SNS リクエストのうち、毎月最初の 100 万件は無料

 それ以降、Amazon SNS リクエスト 100 万件ごとに 0.50¬USD

 https://aws.amazon.com/jp/sns/pricing/

つらかった所

  • Amazon SNS用語やエンドポイントとトピックの関係などに慣れる必要がある

  • 大量配信先を事前に決められない時がつらい

  Amazon SNSのpush通知送信API(publish)は、1つのエンドポイントARN、もしくはトピックARNを対象するため、トピックに紐付かない複数のエンドポイントへの送信ができないようです。送信数分ループ処理をする事になりますが、送信数によってはサーバー負荷となってしまいそうです。

以上、トピック型のモバイルPush通知をRails + Amazon SNSで同じく実装した時に整理したことなどを纏めてみました。

これからPush通知の運用する中で、新たに問題点や改善点も出てきそうですが何かしらの形で共有できればと思います。


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

Vue.jsとRailsの最適な融合を考える

もう新年を迎えて2ヶ月が経ちますね。 多くの人は新年の目標を立てますが、皆さんは何かしら立てましたでしょうか? 英語を毎日勉強するという目標を立てましたが、既に挫折してしまったエンジニアの村上(pipopotamasu (pipopotamasu) · GitHub)です。

本日はその懺悔も込めてVue.jsとRailsの話をお送りします。

f:id:ec0156hx39:20180221120941p:plain

この記事を書く背景

以前ブログで書いた通り、現在Webpackerへの移行を機にフロントエンド周りの改善を進めています。 tech.medpeer.co.jp その中でVue.jsとRailsをいい感じに組み合わせるにはどうしたら良いかについて悩むことがあったので、本記事にて共有させていただきます。 悩んだ内容としては以下になります。
1. Ajax通信時にCSRFトークンをどう設定すればいいか?
2. 単一ファイルコンポーネントで書くHTMLをもっと効率よく書けないか?
3. 単一ファイルコンポーネントとフォームヘルパーの兼ね合い

実行環境

今回の記事ではサンプルコードを多く載せています。主なもののバージョンは以下になります。

  • Rails 5.1.4

  • Webpacker 3.2.1

  • Vue.js 2.5.13

  • axios 0.17.1

  • pug 2.0.0-rc.4

1.Ajax通信時にCSRFトークンをどう設定すればいいか?

RailsではAjax通信時、破壊的なHTTPメソッド(POSTとかDELETEとか)を送る場合はCSRFトークンの検証が必要となります。 jquery-railsのGemで上記のようなAjax通信を行う場合は、Gemの方でCSRFトークンをリクエストヘッダーに自動的に含めてくれるのですが、jQuery以外だとそうはいきません。 自身でCSRFトークンをリクエストヘッダーに含める必要があります。

ではどういった風にVue.jsでCSRFトークンをリクエストヘッダーに含めたら良いのでしょうか? 私自身どうしたらいい感じに実装できるか悩んだため、ここで悩んだ末にたどり着いた実装をご紹介します。

なお、今回のケースではHTTPクライアントとしてaxiosを使用します。

リクエストヘッダーにCSRFトークンを仕込んでみる

通常、Railsが提供しているフォームヘルパーを利用せずにAjax通信を行う場合は明示的にCSRFトークンを設定する必要があります。

設定しない場合

まずはAjaxを送る部分のソースを見てみます。
f:id:ec0156hx39:20180212221613p:plain:w220

上記のようにCSRFトークンを設定しない状態だと...

f:id:ec0156hx39:20180212222555p:plain

このようなエラーが起こるので、ちゃんと通るようにしてみましょう。

設定する場合

今度はCSRFトークンを設定する場合です。
f:id:ec0156hx39:20180212221532p:plain:w700

上記の10行目でCSRFトークンの取得、11行目でリクエストヘッダーにトークンを設定しています。

f:id:ec0156hx39:20180212222617p:plain

今回は上手く処理されました。

この方法はViewのDOM内のCSRFトークンを直接取得し、axiosに設定する方法です。

実はこれと同じ方法を提供してくれるパッケージがあります。 次はそのパッケージ、「rails-ujs」を用いた方法を見てみましょう。

rails-ujsを使ってみる

まずはrails-ujsをインストールします。

yarn add rails-ujs

次にrails-ujsからcsrfTokenを取得するメソッドをimportし、CSRFトークンを設定してみましょう。

f:id:ec0156hx39:20180214161913p:plain:w500

これで先ほどの例のように破壊的なリクエストを送ることができるようになります。

Vue.jsのPluginにしてみる

ここまででCSRFトークンを設定するところまでみてきました。 上記の例だと、1つのエントリーファイルに1つのコンポーネントしかありませんでしたが、実際には複数のコンポーネントを使用することが多いと思います。

その場合、上記の例だとaxiosで破壊的なHTTPメソッドを使用する全てのコンポーネントに...

  • axiosをimport

  • rails-ujsをimport

  • リクエストヘッダーにCSRFトークンを設定

しなければなりません。正直面倒です。

そこで、Vue.jsのプラグインを作成し上記の処理を一纏めにしてしまいましょう。

まずはVue.jsのPluginを実装します。Pluginを実装することにより、Vueオブジェクトからaxiosを呼び出せるようにしてみましょう。

  • app/javascript/plugins/vue-axios.js f:id:ec0156hx39:20180214165844p:plain

次はエントリーファイルにて上記で作成したPluginをVueに組み込みます。

  • app/javascript/packs/chapter1/index.js f:id:ec0156hx39:20180214175736p:plain

最後にコンポーネントでVue経由でaxiosを呼び出してみましょう。

  • app/javascript/components/chapter1/App.vue f:id:ec0156hx39:20180214175851p:plain:w250

これで各々のコンポーネントで設定しなくていいようになりました。


以上のようにCSRFトークンの設定からコンポーネント内でのaxiosの呼び出しについて、メドピア内でどうしているかをみていきました。 皆さんはVue.jsとRailsを組み合わせる時、どのようにしているでしょうか?

2.単一ファイルコンポーネントで書くHTMLをもっと効率よく書けないか?

RailsのViewは独自のテンプレートエンジンを用いることで、効率的にHTMLコーディングをすることが可能になります。 ほとんどの人はHamlやSlimといったテンプレートエンジンを使って効率化しているのではないでしょうか?

Vue.jsの単一ファイルコンポーネントのデフォルトのテンプレートは通常のHTMLです。 せっかくRailsはHamlやSlimを使用しているのに、単一ファイルコンポーネントはHTMLって非効率です。

JSのテンプレートエンジンといえば、Vue.jsに組み込まれているMustachやEJSが有名ですが、今回の目的はHTMLの効率的なコーディングです。どうやらHTMLの省略記法に対応しているのはそれほど多くなく、調べてみた結果「Pug」の一強のようです(もし他に有名なやつがあればこっそり教えてください)。

そこで、メドピアではPugを採用することにしました。

Pugとは

Hamlに影響を受けたテンプレートエンジンです。ただHamlに影響を受けたという割にSlimに近いシンタックスだったりします笑

元々Jadeという名前でしたが、すでにJadeが商標登録?されていたためPugにしたらしいです。 上記でも書いたように、
・HTMLの省略記法に対応
・JSの実行

という特徴があります。 それでは次にPugを使って単一ファイルコンポーネントを書くとどのようになるのかを見ていきましょう。

Pugを使用しない場合

と言いつつも、Pugを使用した場合と使用しない場合のbefore/afterが見れた方が良いので、まずはpugを使用しない場合の単一ファイルコンポーネントを見てみます。 今回の例ではみんな大好きTodoリストです。

  • app/javascript/components/chapter2/TodoApp.vue f:id:ec0156hx39:20180215114443p:plain

まあ普通のHTMLですね笑

次はPugを使用する例を見ていきます。

Pugを使用する場合

まずはPugのパッケージと、WebpackでPugをコンパイルするためのpug-loaderをインストールします。

yarn add pug pug-loader

後は単一ファイルコンポーネントのtemplateタグにlang="pug"とpugの設定をするだけです。 Webpackerの場合はコンフィグファイルに設定を追記する必要はありません。

どうなるかというと...

  • app/javascript/components/chapter2/PugTodoApp.vue f:id:ec0156hx39:20180215120942p:plain

このようにかなりスッキリさせることができました。

みなさんも是非pugを使って見てください!

3.単一ファイルコンポーネントとフォームヘルパーの兼ね合い

Vue.jsの単一ファイルコンポーネントはとても便利です。 以前技術ブログでも書きましたが、単一ファイルコンポーネントは以下のようにとても便利です。 * シンタックスハイライト * 1ファイル内にテンプレートとテンプレートに適用するJavaScript、cssを書くことができる(コンポーネント化できる) * テンプレート内でES6以降のものが使用できる

しかし残念なことに、単一ファイルコンポーネントではRailsのフォームヘルパーを使うことができません。 かといって本来フォームヘルパーで生成されるはずのHTMLを単一ファイルコンポーネント内のテンプレートに直書きするのも効率が悪いです。

比較

そこで、単一ファイルコンポーネントでテンプレートを書くかRailsのViewでテンプレートを書くか比較してみることにしました。 今度はユーザーの新規作成画面を例にしてみます。

単一ファイルコンポーネント内にテンプレートを書く場合
  • app/javascript/components/chapter3/UserRegisterForm.vue f:id:ec0156hx39:20180220122803p:plain

全体としてこのような感じになりました。 このテンプレートを作ってみて面倒だったのはformタグとhidden要素です。

f:id:ec0156hx39:20180220123017p:plain

フォームヘルパーならform_forやform_withで一行で作成できる部分をわざわざ手打ちしてHTMLを書かなければなりません。 またhiddenのinputタグのauthenticity_tokenのvalueですが、フォームヘルパーなら自動的にCSRFトークンが設定されるところ、わざわざ自身で設定しなければなりません。

f:id:ec0156hx39:20180220123451p:plain

結果、テンプレートを作成するのにそこそこ手間がかかってしまいました。

RailsのViewにテンプレートを書く場合

今度はフォームヘルパーを活用する場合です。全体としてはこんな感じになります。

  • app/views/chapter3/new.html.erb f:id:ec0156hx39:20180220123722p:plain

先ほどのformタグとhiddenの件はフォームヘルパーのおかげでかなり楽になりました。 他にもtext_fieldやlabelなどのヘルパーのおかげで、全体的にコード量が減っているのが見て取れます。

しかし、もちろんデメリットもあります。

  • Vue.js部分がシンタックスハイライトが効かないため若干見にくい

  • polyfillが効かない

  • テンプレートとコードが離れる

デメリットこそありますが、メドピア内ではフォームヘルパーを使う時は基本的に単一ファイルコンポーネントを使わず、RailsのViewにテンプレートを書く方法にしています。

まとめ

今回の記事ではメドピア内でRailsとVue.jsを組み合わせる時に悩んだ3つのことと、その対応策を書いてみました。

使用したサンプルコードは以下のリポジトリに置いてあります。

GitHub - medpeer-inc/medpeer-dev-blog2

正直これがベストなソリューションであるという確信はありません。

もし、「もっといい方法があるよ」というアイデアがある方は是非メドピアに遊びに来てそれをご教授ください。 「俺がもっといけてるコードにしてやるぜ!」という方は一緒に働きましょう!

またメドピアでは毎週火曜日にVue.jsのもくもく会を実施しています。そちらも是非ご参加ください!

medpeer.connpass.com


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

Rails + AWS でモバイルフレンドリーな動画配信サイト構築

あけましておめでとうございます。
メドピアのSRE @kenzo0107 です。

2018年もよろしくお願いします。

今回は昨年リニューアルした動画配信システムについてです。

経緯

これまでのメドピアの動画配信は CloudFront 経由で S3 上の mp4 を video タグで参照し配信してました。

この配信方法では CloudFront でキャッシュしづらく
通信状況によってはファーストビューまでに時間が掛かり、サイト離脱へ繋がります。

また、直リンク禁止の動画の場合、
リファラチェック等をするかと思いますが
一部 IE Edge のバージョンで video タグでリファラ参照ができないという仕様があり*1
既存の仕組みをフロントから変える必要がありました。

以上の経緯から動画配信の仕組みを見直し要件を洗い出しました。

要件

  • 動画は mp4 で納品される為、HLS形式へエンコードする機構を用意する。
  • 通信状況に依らずサクサクと見ることができる様にする。
  • 電波状況によってレートを変換する。あくまで見続けられる。
  • 直リンク禁止にする。
  • 今後を見据えて特定のユーザにのみ閲覧を許可する機能を盛り込む。

システム概要

要件を満たすべく環境構築しました。概要は以下となります。

① S3 Bucket transcoder.raw に mp4 ファイルをアップ
② ファイルアップをトリガーに Lambda を起動
③ Lambda が ElasticTranscoder を呼び出し
④ mp4 を HLS(m3u8+ts)形式 へ変換し S3 Bucket transcoder.processed にアップ
⑤ エンコードの成功・失敗をSlackに通知
⑥ ユーザがサイトにアクセス
⑦ Rails から 認証機能を利用し CloudFront にアクセス
⑧ CloudFront がバケットへ対象ファイルにアクセス

f:id:kenzo0107:20180118162230p:plain

理解を深めるべく AWS コンソールで構築手順をお伝えします。

S3 Bucket 作成

mp4 ファイルをアップロード先のバケット(transcoder.raw)、
エンコードされたファイルを格納するバケット(transcoder.processed
を作成します。

transcoder.raw のポリシー作成

mp4 ファイルをアップロード元を許可します。

今回は以下からの全アクションを許可しています。

  • 管理画面URL(https://admin.example.com)
  • 社内IP
{
    "Version": "2012-10-17",
    "Id": "transcoder.raw",
    "Statement": [
        {
            "Sid": "allow-referer",
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::transcoder.raw/*",
            "Condition": {
                "StringLike": {
                    "aws:Referer": [
                        "https://admin.example.com/*"
                    ]
                }
            }
        },
        {
            "Sid": "allow-ad-referer",
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::transcoder.raw/*",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": [
                        "<社内 IP>/32"
                    ]
                }
            }
        }
    ]
}

Elastic Transcoder 作成

動画変換の肝の部分です。

以下の様に Elastic Transcoder のパイプラインを作成していきます。

f:id:kenzo0107:20180117133302p:plain

任意(Optional) で 動画エンコード成功可否について通知設定があります。

完了時(On Complete Event)とエラー発生時(On Error Event)にSNS 経由で Slack に通知する様にしました。

f:id:kenzo0107:20180117133342p:plain

CloudFront 作成

S3 bucket transcoder.processed を参照する CloudFront を立てます。

  • delivery method は Web です。

Origin 設定

  • Restrict Bucket Access : Yes ... アクセス制限を設定します。
  • Origin Access Identity : Create a New Identity ... ID を作成します。
  • Grant Read Permissions on Bucket : Yes, Update Bucket Policy ... transcoder.processed へのアクセスポリシーを更新します。

f:id:kenzo0107:20171224161208p:plain

Behavior 設定

ここで重要なのは Restrict Viewer Access (Use Signed URLs or Signed Cookies) の設定を Yes にすることです。
これにより署名付き URL/Cookie のみ CloudFront へのアクセスが可能となり、直リンクを防止できます。

f:id:kenzo0107:20180120232221p:plain

また、Whitelist Headers で Origin を追加し S3 transcoder.processed の CORS でアクセス許可する URL を絞ることができます。

f:id:kenzo0107:20180120232319p:plain

Distribution 設定

  • Alternate Domain Names : cdn.example.com ... Cookie を有効化させる為、参照元ドメイン (example.com) のサブドメインとします。
  • SSL Certificate : Custom SSL Certificate ... *.cloudfront.net というドメインでなく固有のドメインを利用する為、カスタムSSL証明書を選択
  • Custom SSL Certificate Support : Only Clients that Support Server Name Indication (SNI) 選択します
  • Security Policy : 特に希望なければ recommended を選択します。

f:id:kenzo0107:20180120232946p:plain

  • Supported HTTP Versions : HTTP/2, HTTP/1,1, HTTP/1.0 ... HTTP/2 の恩恵を受けましょう
  • Logging : On ... アクセスログを取るかどうかの設定です。取れるものは取りましょう!
  • Bucket for Logs : transcoder.processed.s3.amazonaws.com ... ログを貯めるバケットです。エンコードされた動画用バケットを使うこととしました。
  • Log Prefix : logs-transcoder-cloudfront ... 保存するログのプリフィックスです。分かり易くしましょう。
  • Cookie Logging : On ... Cookie ログも取っておきます。

f:id:kenzo0107:20171224163250p:plain

以上設定完了後、Create Distribution ボタンを押下し作成します。

Lambda 作成

Lambda の役割

  1. mp4 ファイルを transcoder.raw にアップしたことを検知し
  2. ElasticTranscoder のパイプラインに渡し
  3. m3u8, ts ファイルを transcoder.processed に格納します

Lambda 関数作成

Lambda 関数の作成で 一から作成 を選択

  • 関数の名前: VideoTranscodingInAWS
  • ランタイム: Node.js 6.10
  • ロール: カスタムロールの作成

新しい IAM ロールの作成

ロール名: lambda_elastictranscoder_execution

  • ポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:ap-northeast-1:xxxxxxxxxxxx:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::transcoder.raw/*",
                "arn:aws:s3:::transcoder.processed/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "elastictranscoder:CreateJob"
            ],
            "Resource": "arn:aws:elastictranscoder:ap-northeast-1:xxxxxxxxxxxx:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "sns:Publish"
            ],
            "Resource": "arn:aws:sns:ap-northeast-1:xxxxxxxxxxxx:ElasticTranscoderNotificationToSlack"
        }
    ]
}
  • 信頼ポリシー
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

スクリプト

ハンドラ: index.handler

'use strict';

var AWS = require('aws-sdk');
var s3 = new AWS.S3({
    apiVersion: '2012-09-25'
});

var transcoder = new AWS.ElasticTranscoder({
    apiVersion: '2012-09-25',
    region: 'ap-northeast-1'
});

// return dirname without extensionn
function dirname(path) {
    var p = path.split(path.sep).pop().split('.')[0];
    return decodeURIComponent(p);
}

exports.handler = function(event, context) {

    console.log('Executing Elastic Transcoder Orchestrator');

    var bucket = event.Records[0].s3.bucket.name;
    if (bucket !== 'transcoder.raw') {
        context.fail('Incorrect Video Input Bucket');
        return;
    }
    
    var pipelineId = '<ElasticTranscoder Pipeline ID>';
    var key = event.Records[0].s3.object.key;
    var dkey = dirname(key);

    console.log("(^-^)key");    
    console.log(key);
    console.log(dkey);

    var params = {
        Input: {
          Key: key,
          FrameRate: 'auto',
          Resolution: 'auto',
          AspectRatio: 'auto',
          Interlaced: 'auto',
          Container: 'auto',
        },
        PipelineId: pipelineId,
        Outputs: [
          {
            Key: dkey + '/600k/s',
            PresetId: '1351620000001-200040', // hls 600k
            SegmentDuration: '10'
          }
          ,{
            Key: dkey + '/1M/s',
            PresetId: '1351620000001-200030', // hls 1M
            SegmentDuration: '10'
          }
          ,{
            Key: dkey + '/2M/s',
            PresetId: '1351620000001-200010', // hls 2M
            SegmentDuration: '10'
          }
          ,{
            Key: key,
            PresetId: '1351620000001-000010', //Generic 720p - mp4
            ThumbnailPattern: dkey + '-{count}'
          }
        ],
        Playlists: [
          {
            Name: dkey,
            Format: 'HLSv3',
            OutputKeys: [
                dkey + '/600k/s', 
                dkey + '/1M/s', 
                dkey + '/2M/s'
            ]
          }
        ]
    };

    transcoder.createJob(params, function(err, data){
        if (err) {
            console.log(err, err.stack);
            context.fail();
            return;
        }
        context.succeed('Job well done');
    });
};

<ElasticTranscoder Pipeline ID> を先ほど作成した Pipeline ID を設定してください。

上記スクリプトの要点は以下です。

ビットレート毎 (600k, 1M, 2M) に出力

f:id:kenzo0107:20171224150345p:plain

上位の m3u8 ファイルによってビットレート毎の再生ファイルが管理されています。

帯域に余裕がある場合はプレイヤー側で
高いレート(2M: High)のファイルを選択するようになり
高画質の動画が閲覧できます。

逆に帯域に余裕がなく通信状況が悪い場合は
低いレート(600k: Low)を選択するようになります。

これによってユーザの通信状況にリアルタイムに合わせたレートでのストリーミング配信が可能になります。

ディレクトリ構成を担保したままファイル出力

transcoder.raw にアップしたディレクトリ構成を担保した状態で transcoder.processed に ts ファイルを作成するようにしています。
既存環境からの移行を加味しました。

SLACK_WEBHOOK_URL を変数で設定

環境変数として設定することで Slack 通知先を変えられる様にしました。

transcoder.processed アクセスポリシー確認

Elastic Transcoder での IAM 作成や
CloudFront での Bucket ポリシーをアップデート処理で
以下の様なポリシーが生成されています。

{
    "Version": "2012-10-17",
    "Id": "Policy-CDN",
    "Statement": [
        {
            "Sid": "Stmt1505204403832",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<aws account ID>:role/Elastic_Transcoder_Default_Role"
            },
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::transcoder.processed/*"
        },
        {
            "Sid": "2",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <CloudFront Origin Access ID>"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::transcoder.processed/*"
        }
    ]
}

S3 Bucket transcoder.raw で mp4 ファイルアップロード検知

S3 Bucket transcoder.raw の プロパティ > Events 設定します。

  • ObjectCreate (All) にチェックを入れます。
  • サフィックス : mp4
  • 送信先 : Lambda 関数
  • Lambda : VieoTranscodingInAWS

上記設定し保存します。

名前入れなくとも、自動で名前が生成されます。

f:id:kenzo0107:20171224170139p:plain

これで transcoder.raw に mp4 ファイルをアップロードすると Lambda が検知し
HLS ファイルを生成してくれる様になります。

エンコードされるか試してみる

transcoder.rawmedpeer/soreha/sutekina/shokuba とフォルダを作成し mp4 ファイルをアップロードします。

無料動画素材を以下から取得しました。*2

http://www1.nhk.or.jp/archives/creative/material/view.html?m=D0002060315_00000

f:id:kenzo0107:20180117171230p:plain

Slack から通知が届きました。*3

f:id:kenzo0107:20180117171920p:plain

エンコードしたファイルの格納先 transcoder.processed を見てみます。
無事フォルダ構成を担保したまま HLSフォーマット m3u8 ファイルやサムネイル画像が出力されています。

transcoder.processed/medpeer/soreha/sutekina/shokuba/
├── D0002060315_00000_V_000/
│        ├── 1M/
│        │         ├── s.m3u8
│        │         ├── s0000.ts
│        │         ├── ...
│        │         └── s0004.ts
│        ├── 2M/
│        │         ├── s.m3u8
│        │         ├── s0000.ts
│        │         ├── ...
│        │         └── s0004.ts
│        └── 600k/
│        │         ├── s.m3u8
│        │         ├── s0000.ts
│        │         ├── ...
│        │         └── s0004.ts
├── D0002060315_00000_V_000-00001.png
├── D0002060315_00000_V_000.m3u8
└── D0002060315_00000_V_000.mp4

Rails 改修

CloudFront の認証機能によってローカルの Rails on Vagrant から参照できる様にします。
ローカル Rails 環境のドメインを dev.example.com とします。

CloudFront 認証機能には以下 2つの方法があります。

  • 署名付き URL
    • URL に対して CloudFront 認証情報・期限を URL パラメータで渡す
    • CloudFront 上の 1 ファイルに対して認証設定可能*4
  • 署名付き Cookie
    • Browser 上に CloudFront 認証情報・期限を Cookie に保存
    • CloudFront 上の 複数のファイルに対して認証設定可能

上記特性より
署名付き URL を 1ファイルで動画を構成する mp4 で
署名付き Cookie を 複数ファイルで動画を構成する m3u8 で
試験したいと思います。

作成・修正するファイルリストです。

  • app/config/secrets.yml
  • app/controllers/concerns/common.rb
  • app/controllers/hoges_controller.rb (署名付き URL 用)
  • app/views/hoges/index.html.erb (署名付き URL 用)
  • app/controllers/moges_controller.rb (署名付き Cookie 用)
  • app/views/moges/index.html.erb (署名付き Cookie 用)

app/config/secrets.yml

AWS console 上で root 権限で作成した Key Pair IDと Private Key を設定しています。

development:
  secret_key_base: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  cloudfront_key_pair_id: xxxxxxxxxxxxxxxxxx
  cloudfront_private_key: "-----BEGIN RSA PRIVATE KEY-----\nxxxxxxxxxxxxxxxxxxxxx+xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n-----END RSA PRIVATE KEY-----"

app/controllers/concerns/common.rb

module Common
  extend ActiveSupport::Concern

  # CloudFront 署名付き Cookie 認証用 Cookie 設定
  def cookie_data(resource, expiry)
    raw_policy = policy(resource, expiry)
    {
      'CloudFront-Policy' => safe_base64(raw_policy),
      'CloudFront-Signature' => sign(raw_policy),
      'CloudFront-Key-Pair-Id' => Rails.application.secrets.cloudfront_key_pair_id,
    }
  end

  # CloudFront 用 署名付き URL 取得
  def get_cloudfront_signed_url(resource, expiry)
    expire      = expiry.utc.to_i
    raw_policy  = policy(resource, expiry)
    signature   = sign(raw_policy)
    key_pair_id = Rails.application.secrets.cloudfront_key_pair_id
    "#{resource}?Expires=#{expire}&Signature=#{signature}&Key-Pair-Id=#{key_pair_id}"
  end

  private

  def policy(url, expiry)
    {
      "Statement" => [
        {
          "Resource" => url,
          "Condition" => {
            "DateLessThan" => { "AWS:EpochTime" => expiry.utc.to_i },
          },
        },
      ],
    }.to_json.gsub(/\s+/, '')
  end

  def safe_base64(data)
    Base64.strict_encode64(data).tr('+=/', '-_~')
  end

  def sign(data)
    digest = OpenSSL::Digest::SHA1.new
    key    = OpenSSL::PKey::RSA.new Rails.application.secrets.cloudfront_private_key
    result = key.sign digest, data
    safe_base64(result)
  end
end

署名付き URL

app/controllers/hoges_controller.rb

署名付き URL を生成します。
mp4 ファイル URL に対して10秒間有効な期限付き動画 URL を生成します。*5

class HogesController < ApplicationController
  include Common

  def index
    # 署名付き URL
    @cloudfront_signed_url = get_cloudfront_signed_url(
      'https://cdn.example.com/medpeer/soreha/sutekina/shokuba/D0002060315_00000_V_000.mp4',
      10.seconds.from_now
    )
  end
end

app/views/hoges/index.html.erb

<video poster="https://cdn.example.com/medpeer/soreha/sutekina/shokuba/D0002060315_00000_V_000-00001.png" preload="auto" controls="controls">
  <source src='<%= @cloudfront_signed_url %>' type='video/mp4'>
</video>

署名付き URL を確認してみる

動画が再生されました!
生成された期限付き動画URLを確認すると非常に長いURLパラメータが付与されていることが確認できます。

f:id:kenzo0107:20180118114358p:plain

そして 10 秒後、ソースから生成された期限付きURLを別途プライベートモードのブラウザで開こうとすると閲覧不可状態となっていることがわかります。

f:id:kenzo0107:20180118114949p:plain

署名付き Cookie

app/controllers/moges_controller.rb

10秒間有効な期限付きの署名付き Cookie を生成します。

class MogesController < ApplicationController
  include Common

  def index
    # 署名付き Cookie
    @cloudfront_url = 'https://cdn.example.com/medpeer/soreha/sutekina/shokuba/D0002060315_00000_V_000.m3u8'
    cookie_data('https://cdn.example.com/*', 10.seconds.from_now).each do |k, v|
      cookies[k] = { value: v, domain: 'example.com', path: '/' }
    end
  end
end

app/views/moges/index.html.erb

簡易的にクラウド上の hls.js 上を利用していますが、実際にはダウンロードして利用しています。

<video id="video" width="600" height="300" class="video-js vjs-default-skin" controls>
<script src="https://cdn.jsdelivr.net/npm/hls.js"></script>
<script>
if(Hls.isSupported()) {
  var video = document.getElementById('video');
  var config = {
    xhrSetup: function(xhr, url) {
      xhr.withCredentials = true;
    }
  }
  var hls = new Hls(config);
  hls.loadSource("<%= @cloudfront_url %>");
  hls.attachMedia(video);
  hls.on(Hls.Events.MANIFEST_PARSED,function() {
    // video.play();
  });
}
</script>

署名付き Cookie を確認してみる

署名付きURLとは異なり、埋め込まれる動画URLは変更ありません。
CloudFront 関連 Cookie が追加されていることがわかります。

  • CloudFront-Signature
  • CloudFront-Policy
  • CloudFront-Key-Pair-Id

f:id:kenzo0107:20180118124520p:plain

そして 10 秒後、Cookie を保存した同一のブラウザ上で 動画URL にアクセスしてみると閲覧不可状態となっていることがわかります。

https://cdn.example.com/medpeer/soreha/sutekina/shokuba/D0002060315_00000_V_000.m3u8

f:id:kenzo0107:20180118114949p:plain

また動画再生中に通信状況を意図的に劣化させることで再生される動画ファイルが変更されている様子がわかります。
是非試してみてください♪

元々の mp4 が ts ファイルに小分けにされキャッシュヒット率も上がり CloudFront 的にもメリットが大きいと感じました。

以上で Rails + AWS で直リンク対策を施した HTTP Live Streaming 動画配信環境が構築できました。

おまけ

既存動画バケットから新規バケットへ移動

新たに今回の HLS 動画閲覧システムを作成した後、
既存の mp4 を格納した S3 Bucket から移行が必要かと思います。

そんな時はコレ

macOSX%$ aws s3 sync --profile <profile> --region <region> \
--exclude "*" \
--include "*.mp4" \
s3://<既存の mp4 バケット名>/ \
s3://transcoder.raw/

初回動画確認前には Invalidation でキャッシュ削除

意外とハマりました。
CloudFront で誤ったキャッシュを保持しアクセスしても期待した動作にならない事象があった為、
問題があった場合はキャッシュを削除しておくと問題の切り分けができます。

以上
参考になれば幸いです。


是非読者になってください(︎ ՞ਊ ՞)︎


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

Rails未経験/経験年数が2年以内のポテンシャルエンジニア絶賛募集中です!*6

Rails開発してみたい人、新しい技術に意欲的な人、リードエンジニアに教育されてみたい(?)人、医療に関わるサービス開発を行ってみたい人、その他メドピアに興味を持った方などは、是非、コンタクトを取って頂ければと思います!

*1:Microsoft Edge 40 1506300, Microsoft EdgeHTML 15.15063 でリファラ参照不可であることを確認しています。

*2:NHKクリエイティブ・ライブラリーで無料提供されている動画素材です。試験するのにうってつけでした。

*3:ElasticTranscoder のイベントログを加工してます。

*4:複数の URL に対して署名付きURL生成設定すれば複数設定は可能

*5:試験の為、10秒間としています。

*6:リードエンジニアも募集中です!

2017年に始めたメドピアエンジニアチームの新しい取り組みについて

こんにちは。メドピアにエンジニアとしてJoinして7ヶ月、Ruby/Rails歴≒メドピア歴の小林です。

2017年もあとわずかとなりました。今回のブログでは、1年間を振り返る意味も込めて、今年メドピアのエンジニアチームが始めた新しい取り組み(コードレビュー2段階体制・レビュー振り返り会・bundle update当番)についてご紹介したいと思います。

コードレビュー2段階体制について

メドピアでは、コードのレベルを一定に保つため、コードレビューを行っています。また、Railsの経験が豊富なエンジニア(以下、リードエンジニア)がLGTMを出さないとマージできない体制にしています。

特に、Ruby/Rails歴が短い私のようなエンジニアにとっては、先輩のエンジニアにいただけたレビューで今まで見れていなかった問題点に気づけたり、よりわかりやすいコードを書くためのヒントを頂けたりするので、このような体制で開発できることがとてもありがたいと感じています。

しかし私の入社当時(5月頃)には、レビュー体制で抱えている課題がありました。Rails未経験でJoinしたエンジニアが増えてきた際に、未経験エンジニアの数に対しリードエンジニアが少なかった時期があり、レビューの速度アップが必要になったことがあったのです。

そこで、下記のようなレビュー2段階体制を導入するようになりました。

①ファーストレビュー
…Rails未経験/経験年数の比較的浅いエンジニアが他者のコードをレビューする。
Rails経験が浅くても気づけるコードの問題を指摘してセカンドレビュアーの負担を軽減する。

②セカンドレビュー
…一定以上の年数Rails開発経験のあるリードエンジニアがコードレビューする。
主にRailsWayに則った実装が行われているかを確認する。

※ファーストレビューでLGTMがついた場合でも、
セカンドレビュアーによるレビューでLGTMがつかないと基本的にはマージできない。
※セカンドレビュアーが最初からレビューできる場合はファーストレビューは飛ばして良い。

この体制が導入されたおかげで、私も他の人が書いたコードをレビューする機会ができ、自分でコードを書く時とは違った視点でコードを見る機会も生まれ、そのぶん勉強になったと感じています。

最近はリードエンジニアが増えたため、2段階レビューせず最初からリードエンジニアにレビューして貰えることもあります。スケジュールも加味しつつ時と場合より柔軟な体制をとっています。

未経験エンジニアもレビューに関わることで成長したい/させたいが、その人のレビューだけでマージしてしまうには不安がある場合もあると思います。そのようなチームにはこの2段階体制レビューの取り組みをおすすめしたいと思います。

「レビュー振り返り会」と技術共有の場

コードレビューでは、各プロジェクトに固有の問題ではなく開発全体に影響する問題など、機能開発者とそのレビュアーだけでなくエンジニア全員に周知しておいたほうが良いような問題が上がることがあります。

メドピアでは、その週にレビューで上がった上記のような事項を週に1回エンジニア全体に共有・話し合いをする会として「レビュー振り返り会」を今年から開始しました。

Ruby/Rails中上級者が当たり前に知っていて既にみんなわかっているだろうと思うようなことでも、初心者からすると知らないことが多いです。

振り返り会があるおかげで他のRails未経験だったメンバーがどのようにレビューされているかも知ることができます。

Ruby/Rails以外の開発課題についての振り返りもありますが、その際もそれぞれのエンジニアによって違う範囲の知識に詳しかったりするので、知見を共有することでお互いに勉強になっていることが多いと思います。

また、下記のような開発指針について話し合われたこともありました。

  • Rails5.1から使えるようになったform_withform_forとどのように使い分けるか
  • integerカラムのbigint化の進め方について
  • 類似機能を持ったGemの選定について
  • RubocopやEslintのLintルールの変更について

過去にどのような話題が上がったかは社内ドキュメントにまとめているため、あとからJoinしたメンバーになぜそこがそのような実装になっているのかを説明する時に「○月○日の振り返り会の時に皆でこのような経緯で話し合った」という証跡として使えることがあります。

f:id:marikokobayashi:20171227141221j:plain

bundle update当番

Railsで開発しているチームでどのようにbundle updateを行うかというのは1つの懸念事項として上がってくると思います。

メドピアでは以前はbundle updateの頻度が低かったのですが、Rails5.1に上げたことをきっかけにその頻度を増やしていこうということで、毎週Botで当てられた人がbundle updateを行う、bundle update当番というものを開始しました。

差分のソースを読んで既存の仕組みに影響がないかどうか当番が確認するため、導入されているGemがサイト内でどのように使われているか、しだいに覚えていくようになります。

また、気になる差分があったらマージする前に前述の振り返り会で全員に共有しています。

今週も、ちょうど今年最後のbundle updateがリリースされたところです。これで気持ちよく新年を迎えられそうです。

まとめ

メドピアは、昨年に独自フレームワークからRailsへの移行を開始したこともあって、エンジニアチームのメンバーの半数以上が今年に入ってから加わったメンバーで構成されています。

新しく入ってきたメンバーも既存の仕組みに気になることがあれば気兼ねなく改善のアイディアを出していけるような雰囲気で開発しています。

他にも、週に1回Ruby/Railsに関する書籍やWebサイトの輪読会を行ったり、フロント側ではVue.jsもくもく会を開催したり、2泊3日の開発合宿に定期的に行ったりと、エンジニアが技術研鑽を行える体制が整っており、これから成長したいエンジニアにとってはとてもいい環境だと思います。

現在メドピアでは、Rails未経験/経験年数が2年以内のポテンシャルエンジニアを絶賛募集中です!*1

Rails開発してみたい人、新しい技術に意欲的な人、リードエンジニアに教育されてみたい(?)人、医療に関わるサービス開発を行ってみたい人、その他メドピアに興味を持った方などは、是非、コンタクトを取って頂ければと思います!


是非読者になってください(︎ ՞ਊ ՞)︎


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

*1:リードエンジニアも募集中です!