メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックリーディングカンパニーのエンジニアブログです。PHPからRubyへ絶賛移行中!継続的にアウトプットを出し続けられるようにみんなでがんばりまっす!

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:20180118121100p:plain

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

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:20171224162454p: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:リードエンジニアも募集中です!

開発合宿!?それならペンションでしょ!! 〜群馬県みなかみ町へのメドピア開発合宿日誌〜

こんにちは。メドピアエンジニアの保立です。
今回は、題名の通り、開発合宿についてです。
メドピアでは、年に2, 3回開発合宿を行なっており、前回の合宿から、僕が合宿の幹事をやらせてもらってます。
今回は、11/29〜12/1の3日間、群馬県のみなかみ町にある谷川くるみ村のペンション木馬さんにお世話になりました。
ペンション木馬さんでは、開発合宿プランが用意されており、今回はこの開発合宿プランで合宿を行いました。
11名での参加でしたが、「貸切」で使わせていただくことができました。
ペンションでの開発合宿が思いのほか良かったので、感想を書いていきます。

ペンションをオススメする理由

合宿先を選定する上で、(1)少人数部屋の用意、 (2)セキュリティー、 (3)予算 の3点で悩まれる方が多いと思います。
合宿をペンションで行うことで、この問題が簡単に解決するんです。

1) 少人数部屋が用意しやすい
エンジニアが中心となる開発合宿では、男性ばかりの合宿になることが多いですが、女性エンジニア・デザイナーも気軽に参加できるように、女性部屋の用意は必須となります。
今回参加表明をした女性社員は2名だったので、2名部屋を用意する必要がありました。
開発合宿の限られた予算内で2名以下の部屋を用意するのは意外と大変なんです。
少人数での利用客が多いペンションでは、女性が少数でも専用部屋を簡単に用意できます。

2) 戸締りがしっかりしている
戸締りの必要性は、会社の文化によってまちまちかもしれませんが、メドピアではPCから離れて食事に行く場合、PCが施錠された場所にあることが必要です。
以前、コワーキングスペースで開発合宿を行なった際は、PCを作業場に置きっぱなしで食事に出かけられず、PCを持って食事に行きました。(つらかった)

3) 宿泊費が抑えられる
普通のホテルでは、朝食・夕食・会議室付きでだいたい1泊1万円くらいかと思います。
今回は、2泊で15,400円(税込)でした!!!

合宿のテーマ

メドピアでは、開発合宿で行うテーマとして、普段の業務中にはできない技術研鑽や実装したい機能を自ら決めます。
初日の午後から、最終日の午前中までひたすら開発を行い、最終日の午後に成果発表を行います。
テーマの一部をここで紹介します。
「ChatBotによるサービス改善」
「Rubyのバグ改善」
「RailsのGem開発」
「ElasticSearchで検索機能をもっとよくする」
「新サービスの企画とモック作成」

合宿中

  • 部屋

部屋はこんな感じです。床暖房が効いてて暖かかったです。
f:id:kaoruhotate:20171203173926j:plain

  • 開発中

もくもくもくもく。 f:id:kaoruhotate:20171201104409j:plain

息抜きにペンションの前の川に来ています。 f:id:kaoruhotate:20171201104017j:plain

  • ご飯

朝食・夕食はペンションで用意してもらいましたが、とても美味しかったです。
f:id:kaoruhotate:20171201104031j:plain

  • 最終発表

宿でプロジェクターを借りて、無事最終発表までできました。
f:id:kaoruhotate:20171203115641j:plain

ペンション木馬の合宿について

ここからは、Q.A.方式で合宿について振り返ります。
- ネット繋がるの?
今回11名での合宿でしたが、問題なく繋がりました。(VPN接続もできました)
ペンション木馬の亭主さんは、水上町の飲食店のホームページを作ったこともあるそうで、ネットワークにも詳しい方のようでした。
さらに、開発中にこんなことが。 ↓↓↓ ありがたいです。 www.facebook.com

  • 電源タップは必要?
    大人数でない限り不要だと思います。
    多人数用の電源タップが5, 6個用意されてました。
    会社から電源タップを持って行きましたが、いらなかった。。。

  • お風呂はどう?
    大浴場といった形でしたが気持ちよかったです。
    午前10時から午後3時を除いて入浴が可能だったため、開発に行き詰まった際や夜寝る前でも、自由にお風呂に入って体を癒すことができました。

  • 昼食をとるところはある?
    朝食・夕食はペンションで用意してもらいました。とても美味しく、量も十分でした。
    外にも、駅前や水上温泉街など、オシャレな飲食店が多く、バリエーションも豊富でした。
    平日の昼間でも思ったより混んでいたので、大人数の際は事前に予約した方がいいかもしれません。
    今回は、水上名物焼きカレー カフェレストラン亜詩麻(アシマ)さんと窯焼きピザの店ラ・ビエールさんで昼食を食べました。
    両方とも、オシャレでご飯も美味しかったです。

  • 注意点は?
    プロジェクターを借りる場合、HDMI対応のコネクターが必要でした。

まとめ

開発合宿のアウトプットは、宿や作業場に大きく左右されると思います。 今回利用した「ペンション木馬」さんでは、ネット環境や電源の問題で不自由されることは少なく、美味しいご飯で精気を養い、夜間常に利用できる大浴場で疲れを癒しながら作業ができたため、いつも以上のアウトプットが出せました。

開発合宿の宿泊先をインターネットで調べると、旅館やホテルがたくさん出てきます。 しかし、ペンションで合宿をすることで、よりたくさんのエンジニアの参加を促せたり、宿泊費を抑えることができるかもしれません。 開発合宿をする際は、ペンションを考えてみてはいかがでしょうか。
f:id:kaoruhotate:20171203115554j:plain


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


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

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

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

■開発環境はこちら

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

FukuokaRubyKaigiで登壇してきました!!

FukuokaRubyKaigiで登壇してきました

みなさまこんにちは、メドピアCTOの福村です。

FukuokaRubyKaigi、控えめに言って最高でした! (スケジュールがタイトで食を楽しめませんでしたが次回リベンジ。)

regional.rubykaigi.org

キッカケ

メドピアは長くPHPで開発してきたため、Rubyでも開発していると多くの人に知ってもらいたいということがキッカケで めんたいスポンサーというスポンサー枠で参加させていただきました。
運営スタッフの方々に感謝です。本当に大変だったと思います。

発表内容

朝からほぼ全員?と思うくらい席いっぱいで熱量半端なかったです。 OSSへ恩返しというワードが終始出ていたのが印象的でした。今後も感謝の気持ちやお金、行動をわかりやすく実行していきたい。
島田さんの「A Ruby Programming Episode」がとてもすばらしく、おまけにすごい秀逸な終わり方だったため、次で滑るとFukuokaRubyKaigiの印象わるくなってしまうかもというプレッシャーを密かに感じながら喋りました。
前夜祭もLT大会がすごい盛り上がり、エネルギーをもらいました。

ちょっと補足

  • レビュー振り返り会のオススメ
    • コスト低で実施できるのでよいです。是非やってみてください!
  • ノベルティーグッズにマッサージ器
    • メンバーと良さそうなノベルティーグッズは何かね?と議論を重ねて最終的に選ばれたのがこれでした。
    • もらった人はぜひとも使ってやってくださいm( )m
      f:id:akinorifukumura:20171127174821p:plain

まとめ

やりたいこと(やれる)ことは沢山あるもので、だいたいやれそうなものがやれていなかったりで焦りますよね。
一つ一つやれることを着実にやっていくと1年間を振り返ったときいっぱいやってきたなと気づけたりします。
長い道のりなので後悔のない1手を着実に大胆にすすめていきたいと思います。


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


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

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

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

■開発環境はこちら

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

Railsの太ったモデルをダイエットさせる方法について

こんにちは。メドピアのRuby(Rails)化をお手伝いしている@willnetです。最近はよくリファクタリングをしています。

今回は、最近僕がリファクタリングしている内容についてまとめようと思います。

メドピアではFat Model/Controllerを避けるために、rubocopの設定を利用しクラスの行数が300行以下になるよう制限しています*1。最近300行を超えるモデルが出てきたので、一部の処理を別のクラスに切り出し始めました。

このとき、Railsが提供している機能であるconcernsを利用すると楽に行数を減らすことができますが、それだとrubocopの指摘を回避できるという意味しかないので、なるべく委譲(composition)を利用して処理を別クラスに移していっています*2

複数モデルにまたがる処理を切り出す

Railsアプリケーションを書いていると、複数のモデルを一度に使った処理を書きたいケースがあります。その場合は処理をどこに書くのが適切でしょうか?

多くのwebサービスではユーザが起点となる処理が多いので、結果としてUserモデルにメソッドが集まってしまいUserモデルがどんどんFatになります。数百行のレベルで済めば良い方で、千行を超えるUserモデルを持つプロジェクトも多いのではないでしょうか。行数が多いクラスは内容を理解するのが大変ですし、コードを修正した際の影響範囲もすぐにはわかりません。こうなるとコードに触れるのが苦痛になってきます。

これを解決するには、Active Recordを継承しないPORO*3なモデルを作成し、処理を委譲します。

例として、Userモデルに「ブログを投稿して友達に通知をする」メソッドを書いてみます。

class User < ApplicationRecord
  has_many :posts
  has_many :friendships
  has_many :friends, through: :friendships
  
  def create_post_with_notifications!(body)
    transaction do
      posts.create!(body: body)
      friends.each do |friend| 
        friend.notifications.create!("#{name}さんが投稿しました")
      end
    end
  end
end

これはUserモデルに置かれることの多い典型的なメソッドです。Userモデルが投稿と友達の関連元になるので、一見収まりがよく見えます。開発初期でUserモデルが小さいときはとくに問題になりませんが、開発が進むにつれこのようなメソッドが大量に存在するようになり、邪魔になってきます。

そこで、「ブログを投稿して友達に通知をする」という単一目的のクラスを作ってみます。

class PostWithNotifications
  def self.create!(creator:, body:)
    new(creator: creator, body: body).create!
  end
  
  def initialize(creator:, body:)
    @creator = creator
    @body = body
  end
  
  def create!
    ActiveRecord::Base.transaction do
      create_post!
      create_notifications!      
    end    
  end
  
  private
  
  attr_reader :creator, :body
  
  def create_post!
    creator.posts.create!(body: body)
  end
  
  def create_notifications!
    creator.friends.each { |friend| create_notification!(friend) }
  end
  
  def create_notification!(friend)
    friend.notifications.create!("#{creator.name}さんが投稿しました")
  end
end

処理の内容的には以前と変わっていません。ただ、メソッドを切り出したついでに多少リファクタリングしています。単一目的のクラスで管理することにより、メソッドを抽出するなどのリファクタリングがより簡単になりました。

PostWithNotificationsクラスを作ったことにより、メソッド呼び出しがuser.create_post_with_notifications!('投稿内容')からPostWithNotifications.create!(creator: user, body: '投稿内容')に変更されました。インターフェースを変えずに処理を切り出したい場合は、次のように、元のUser#create_post_with_notifications!から処理を委譲するようにするとよいでしょう。

class User < ApplicationRecord
  # 略
  
  def create_post_with_notifications!(body)
    PostWithNotifications.create!(creator: self, body: body)
  end
end

複数のレコードを扱う処理を切り出す

単一のモデルを取り扱う場合でも処理を書く場所に困る場合があります。それは複数のレコードを取り扱うケースです。複数のレコードを取り扱うために、モデルのクラスメソッドに処理を書く場合が頻繁に見られます。モデルが小さい場合はそれでも問題ないですが、先程と同様開発が進むにつれモデルの見通しを悪くする要因になります。そもそも、Active Recordは本来レコードとオブジェクトを一対一でマッピングするデザインパターンなので、複数のレコードを扱うクラスは別に用意するのが適切です。これも先程の例と同じくPOROを使うことで解決できます。

例として、複数のメッセージを既読にする処理を考えてみます。

class Message < ApplicationRecord
  enum status: %i[unread read]
  
  def self.read!(messages)
    messages.unread.each(&:read!)
  end

  # たくさんのメソッド
end

(この例だとMessage.read!メソッドは十分小さいのであまり切り出す必要性は感じないかもしれません。もっと長いメソッドを想像して読み替えてください)

このMessage.read!メソッドはメッセージを扱うので一見妥当な場所に存在するように感じます。しかし、複数レコードを取り扱うクラスを作り委譲させることで、より見通しがよくなるケースが多いです。

class Message::Collection
  def self.read!(messages)
    new(messages).read!
  end

  def initialize(messages)
    @messages = messages
  end

  def read!
    @messages.unread.each(&:read!)
  end
end

Message::Collectionというクラスを作りメソッドを切り出しました。これで開発が進みread!メソッドが複雑になったとしても、全体を容易に把握できるはずです。

単機能として切り出せる処理を切り出す

ここまでの内容に沿って複数モデル、複数レコードの処理をPOROに切り出したあとは、Active Recordのモデルに書かれている処理は基本的に自分のモデルに関わることだけになっているはずです。それでも行数が多く取扱いに困るときは機能ごとにPOROに切り出しましょう。

どういう機能を切り出すべきかはモデルごとに判断するしかないのですが、例えばバッチ用の処理などは共通して切り出しやすいです*4

Active Recordのモデルに対してバッチ処理用のメソッドを生やすよりは、バッチ処理専用の小さいクラスを作ってしまった方が取扱いが楽なケースが多いです。

次のような、未読のメッセージがあったときにユーザにメールを送るようなメソッドがあるとします。

class User < ApplicationRecord
  # ...
  
  def self.notify_unread_messages
    User.active.find_each do |user|
      next unless user.messages.unread.exists?
      UserMailer.not_read_messages(user).deliver_now
    end
  end
end

これをバッチ用のクラスに移動させると次のようになります。

class UnreadMessagesNotification
  def self.notify(user: nil)
    new(user: user).notify
  end

  def initialize(user: nil)
    @user = user || User.active
  end

  def notify
    user.find_each do |user|
      next unless user.messages.unread.exists?
      UserMailer.not_read_messages(user).deliver_now
    end  
  end

  private

  attr_reader :user
end

メソッドを移動させたことでUserクラスからUser.notify_unread_messagesを削除できました。それだけではなく、メール送信の対象となるユーザを外部から注入できるようにしたことで、テストをしやすくなっています。

まとめ

POROを利用してモデルを小さく保つ方法について紹介しました。Railsの初心者は特に「モデルとはActive Recordのことだ」と考えがちです。その固定観念から抜け出すことで、可読性の高いアプリケーションを書く入り口に立てるのではないかと思います。この記事によって少しでも読みやすいRailsアプリケーションが増えることを願っています。


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


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

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

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

■開発環境はこちら

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

*1:コントローラで300行は多すぎるので、いずれコントローラの場合は100行以下にしたいと思っています

*2:concernsについてはまた別の機会に書くかもしれません

*3:Plain Old Ruby Objectの略で、Active Recordなどを継承していないふつうのオブジェクトのことを指します

*4:この話の前提として、「基本的にバッチ用の処理はモデルに書き、rakeタスクはモデルのメソッドを呼び出すだけ」という慣習があります。これにより、処理内容がわかりやすく、かつテストが書きやすくなる効果があります

Webpackerへの移行を機にフロントエンド開発を改善

こんにちは。メドピアでエンジニアをしている村上(pipopotamasu · GitHub)です。 普段はRailsを触っていますが、時々フロントエンド周りの開発もしています。 今回はメドピアの環境におけるWebpacker導入とフロントエンド周りの改善をテーマに記事を書きます。

目次

  1. なぜWebpacker(Webpack)を導入するのか?
  2. フロントエンド改善計画
  3. Webpackerの導入で気をつけたこと

github.com

なぜWebpacker(Webpack)を導入するのか?

Webpackerを導入する背景として、主に2つの課題がありました。

  • JavaScriptのビルド時間が長い

  • パッケージのバージョン管理ができない

JavaScriptのビルド時間が長い

元々メドピアのフロントエンド開発においてBrowserifyというbundlerを使用していました。 しかし、これにはビルド時間が長いという弱点があります。 特に以下の2つの場面でその長さが目につきます。

  • CI, デプロイ, 環境構築時に走るassets:precompileのビルド

  • 開発時のJavaScriptのビルド

現状だと前者はビルドだけで10分近くかかってしまい(JavaScriptだけでなくcssなどの他のアセットのビルドも時間がかかるという理由もありますが)、 後者はBrowserifyと一緒に使っているbabel-plugin-transform-runtimeの実行に時間がかかり、JavaScriptの開発中は3秒ほどの時間がかかります。

依存パッケージのバージョン管理ができない

メドピアの環境のnpmのバージョンが3系であるため、パッケージのバージョンを固定するpackage-lock.json(npm5系から登場)がありません。 そのため、RailsのGemfile.lockのように依存パッケージのバージョンが管理できないという問題があります。


これら2つの課題はWebpackerを導入することで一気に解決されます。

JavaScriptのビルド時間が長い → ビルド時間の短縮

BrowserifyよりWebpackの方がビルドが早くなることのベンチマークは、以下の記事を参考にさせていただきました。

perkframework.com

Browserifyとの比較がとてもわかりやすく掲載されています。 唯一、全くの0からのビルド(Fresh build)がBrowserifyが速度的に上回っていますが、そのようなビルドをするのは初回の環境構築時くらいなのでほとんど気にする必要はないでしょう。

依存パッケージのバージョン管理ができない → yarnによりバージョン管理可能に

Webpacker導入により(というよりWebpackerが依存するyarnというパッケージ管理システムにより)、上記のpackage-lock.json, Gemfile.lockのようにyarn.lockで依存パッケージのバージョン管理ができるようになります。

他にもJavaScript以外のアセットファイルのビルドができたりエコシステムが充実しているという点はBrowserifyからWebpackへの乗り換えで大きな利点でもあります。

このような背景からメドピアではWebpackerを導入することとなりました。

フロントエンド改善計画

しかし、いきなり全てのビルドをBrowserifyからWebpackerに置き換えるということはできません。 今まで書いてきたJavaScriptのコード量が多いため、それをWebpacker用の領域(app/javascripts)に移し替えるのにテストを含め時間がかかるからです。 またせっかく移行するなら、同時にフロントエンドをもっと改善していくチャンスでもあります。 具体的な改善点としては...

  • CommonJSだった部分をES Modulesに置き換える

  • Vue.jsでデータバインディングのみでしか使用していなかったところを、単一ファイルコンポーネントも活用する

  • 単一ファイルコンポーネントのLintを導入する

  • 最新版パッケージへ継続的なUpdate体制の確立

などがあります。 ここで、それぞれの改善点の詳細なポイントについてみていきましょう。

CommonJSだった部分をES Modulesに置き換える

ES Modules(以下esm)に置き換えるメリットとしては個人的には大きく以下2つだと思います。

  1. 実行前にモジュール読み込みのエラーを検知できる

  2. ツールを使うことでコードの最適化ができるようになる

実行前にモジュール読み込みのエラーを検知できる

esmはCommonJSと違い静的構文であるため、コードの実行前に構文解析が走ります。 これにより、開発者はより早い段階で間違いに気づくことができます。

# CommonJS
## module.js
function hoge() {
  return 1 + 1;
}
exports.module = hoge;

## main.js
const fuga = require('./module').fuga
console.log(fuga); // undefined


# esm
## module.js
export function hoge() {
  return 1 + 1;
}

## main.js
import { fuga } from '.module' // <= ここでSyntax Errorが発生


ツールを使うことでコードの最適化ができるようになる

esmを使うことで、コードの最適化ができるようになります。 例えばWebpackにはTree Shakingという機能があります。 ESモジュール形式で書かれたコードをbundleして一つのファイルにする時に、exportしているけどどこからもimportされていない、使われていないコードを削除する機能のことです。

https://webpack.js.org/guides/tree-shaking/

Edgeでもexport/import時にコードの最適化がされるようです。

Previewing ES6 Modules and more from ES2015, ES2016 and beyond - Microsoft Edge Dev BlogMicrosoft Edge Dev Blog

またCommonJSはいわゆるサードパーティなのに対し、esmはECMAScriptで定義されるJavaScriptの標準であるということも置き換えの理由です。

単一ファイルコンポーネントの活用

メドピアではJavaScriptのフレームワークとしてVue.jsを使用しています。Vue.jsの機能として、Vue.jsを適用させるテンプレート(HTML)とJavaScriptを同じファイル内に記述する単一ファイルコンポーネントというものがあります。 単一ファイルコンポーネントを使用するメリットはいくつかありますが、最も大きな理由は可読性の向上です。 可読性の向上については3つのポイントがあります。

  1. シンタックスハイライト

  2. 同一ファイル内にテンプレートとテンプレートに適用するJavaScriptを書くことができる

  3. テンプレート内でES6が使用できる

シンタックスハイライト

単一ファイルコンポーネントを使用しない場合、HTMLファイル内にVue.jsのコードを書く必要があります。 メドピアではViewテンプレートにHamlを採用しているため、Hamlファイル内にVue.jsのコードを書いています。 しかし、Hamlファイル内にVue.jsのコードを書いてもシンタックスハイライトがHamlのコードにしか適用されないため非常に見辛いです。

[単一ファイルコンポーネントのテンプレート] f:id:ec0156hx39:20171027113801p:plain

[Haml内に書いたテンプレート] f:id:ec0156hx39:20171027123647p:plain

上のように、単一ファイルコンポーネント内のテンプレートは見やすくハイライトされ(※お使いのエディタでハイライトのプラグインを入れる必要があります)、一方Haml内に書いたテンプレートはHamlのシンタックスしかハイライトされないため見づらくなってしまいます。

同一ファイル内にテンプレートとテンプレートに適用するJavaScriptを書くことができる

単一ファイルコンポーネントの最大の特徴です。 1ファイル内にHTML, JavaScript(CSSも)が記述できるのでどのJavaScriptがどのHTMLに適用されているかを容易に知ることができます。

f:id:ec0156hx39:20171027124642p:plain

テンプレート内でES6が使用できる

Hamlファイル内ではES6のJavaScriptのコードがトランスパイルされないので古いブラウザのサポートをする必要がある時は使用することができません。 ES6を使いたい場面(特にv-bind時の文字列テンプレートの使用)で使えないことにより、可読性が落ちる場合があります。 以下はinputタグに動的なclassをつける時の例になります。

[単一ファイルコンポーネントのテンプレート] f:id:ec0156hx39:20171027134313p:plain

[Haml内に書いたテンプレート] f:id:ec0156hx39:20171027134321p:plain

単一ファイルコンポーネントのLintを導入する

単一ファイルコンポーネントはHTMLやJavaScriptのLinterが使用できないため、新たに専用のLinterが必要です。 これの導入により、コードの質の担保・コーディングルールの統一を実現します。

最新版パッケージへ継続的なUpdate体制の確立

パッケージは定期的にアップデートしないと差分が大きくなり、いざアップデートしようとすると大怪我をする恐れがあります。 幸いにメドピアではRubyのGemを定期的にアップデートする体制ができているため、それと合わせてパッケージをアップデートしていく体制にしていきたいです。



などなどWebpacker導入を機に、よりJavaScriptの開発をモダンにしていきたい野望があります。

そこで、その野望を実現するために移行計画を立てました。

  1. Webpackerの導入
  2. 一部BrowserifyでビルドしているコードをWebpackerに移植すると同時に単一ファイルコンポーネントのLintを導入する
  3. 徐々にBrowserifyでビルドしているコードをWebpackerに移していくかつCommonJSをES Modulesに置き換え
  4. Webpackerへの完全移行とともにBrowserifyのアンインストール
  5. yarn upgrade体制の確立

Webpackerの導入で気をつけたこと

最後にメドピアにおけるWebpackerの導入で気をつけた部分を共有します。

Docker用の設定

メドピアでは開発環境にDockerを利用しています。 しかし、デフォルトのWebpackerの設定ではhostがlocalhostに設定されているため、ホストOSのブラウザからDocker上の開発環境にアクセスできないため以下のような設定が必要です。

https://github.com/rails/webpacker#development


# webpacker.yml

  dev_server:
     host: localhost # <= ここを0.0.0.0に変更
     port: 3035
     hmr: false
     https: false

config/webpack/environment.jsの拡張

Webpackerの2系ではplugin, loader, aliasの設定はconfig/webpack/shared.jsに記述すればよかったのですが、3系からshared.jsが廃止され、この辺りの設定がnode_modules以下の@rails/webpackerに格納されています。 もちろんalias等の設定を直接node_modules以下に追記することはできません。そのため、今回はwebpack-mergeを使ってconfig/webpack/environment.jsにそれらの設定しました。

# environment.js

const { environment } = require('@rails/webpacker')
const merge = require('webpack-merge')

module.exports = merge(environment.toWebpackConfig(), {
  resolve: {
    alias: {
      vue: 'vue/dist/vue.js'
    },
  },
});

今回はaliasだけですが、plugin, loaderなどの設定もここに追加していけばwebpackの設定がうまいことできそうです。

終わりに

まだまだモダンな環境への移行は道半ばですが、今後も変化の激しいフロントエンドの技術に追従できるように環境の改善及び技術力の向上に努めていきたいと考えています。 その一貫として、メドピアでは毎週火曜日の19:30〜Vue.jsのもくもく会を開催しています。 是非ともご参加あれ!

またRailsエンジニア、フロントエンドエンジニアを絶賛募集中ですので少しでも興味を持った方は一度メドピアに遊びに来てください!


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


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

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

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

■開発環境はこちら

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

生きて腸まで届くマイグレーションツール Phinx

こんにちは。メドピアCTO室 @kenzo0107 です。

Join して4ヶ月、
日々新たな技術に触れ、赤い実弾かせながら過ごしております。

今回は開発環境 DB をコンテナ化した際に使用した Phinx についてです。

Phinx って何?

f:id:kenzo0107:20170829132120p:plain phinx.org

  • PHP (>=5.4) でコーディングできるDBマイグレーション&シードツール
  • composer 管理
  • CakePHP 2.x 系の schema.php の様なファイルを作る必要がない
  • DB の向け先は yml で複数管理可能
  • F/W に依存しない
  • MySQL, PostgreSQL, SQL Server, SQLite に対応


Phinx 採用経緯

メドピアでは以下の様な課題を鑑みてマイグレーションツールを模索していました。

  • PHP 5.x系 で運用している独自F/Wがある*1
  • 既に DB が大規模 (スキーマ数 50程度)
  • シード機能も欲しい
  • マイグレーション/シード機能のないF/Wで運用しているプロジェクトにも適用可能であればしたい
  • PostgreSQL, SQL Server で運用しているプロジェクトにも適用可能であればしたい

元々、
個々人用の開発 DB を本番 DB からセキュリティ上データをマスクした上で
同期する様な機能も検討していましたが
必要最低限で開発ができる状態であれば良いという総意から
シード機能も合わせて求める様になりました。

Phinx はこれらの課題を網羅しており、軽量で使い勝手がよかった為採用に至りました。


Phinx は初めてという方、
既に比較検討されている方、
といらっしゃると思いますので簡単に使い勝手を試していただく意味でも
弊社の利用方法と合わせて実践チュートリアルとして git を用意しました。

検証環境

  • OSX 10.12.5
  • Vagrant 1.9.3
  • VirtualBox 5.1.18
  • Docker version 17.03.1-ce, build c6d412e
  • docker-compose version 1.11.2, build dfed245

やること

  • 複数の DB スキーマへのマイグレーション実行
  • 複数の DB スキーマへのシード実行

システム概要図

Docker on Vagrant で動作確認していきます。

f:id:kenzo0107:20170829161035p:plain

無事 Moby Dock 達の抱える DB 達にデータが届くか試してみたいと思います。

事前準備

macOS%$ git clone https://github.com/medpeer-inc/phinx
macOS%$ cd phinx
macOS%$ vagrant up
macOS%$ vagrant ssh
vagrant%$ cd /vagrant

Adminer, Phinx, DB コンテナ起動

vagrant%$ docker-compose up --build -d
vagrant%$ docker-compose ps

        Name                      Command               State            Ports
---------------------------------------------------------------------------------------
      Name               Command              State               Ports
-----------------------------------------------------------------------------
        Name                      Command               State            Ports
---------------------------------------------------------------------------------------
vagrant_adminer_1      entrypoint.sh docker-php-e ...   Up       0.0.0.0:80->8080/tcp
vagrant_db-migrate_1   phinx --help                     Exit 0
vagrant_mysql_1        docker-entrypoint.sh mysqld      Up       0.0.0.0:3306->3306/tcp
vagrant_pgsql_1        docker-entrypoint.sh postgres    Up       0.0.0.0:5432->5432/tcp
vagrant_sqlsvr_1       /bin/sh -c /opt/mssql/bin/ ...   Up       0.0.0.0:1433->1433/tcp


  • db-migrate コンテナは one-off コンテナとして利用する為、Exit 0 で問題ありません。ビルドするのが目的です。
  • DB は MySQL, PostgreSQL, MSSQL を用意しました。


今回は MySQL をメインに進めたいと思います。

0. DB作成

MySQL, PostgreSQL コンテナではコンテナ起動時に
hogehoge, mogemoge の 2つの DB Schema を作成する様設定しています。

  • docker-compose.yml
...
  mysql:
    image: mysql:5.7
    environment:
    - MYSQL_ROOT_PASSWORD=rootpass
    - MYSQL_DATABASE=hogehoge
    - MYSQL_USER=developer
    - MYSQL_PASSWORD=pass
    volumes:
    - db-data:/var/lib/mysql
    - ./db/conf.d/my.cnf:/etc/mysql/conf.d/my.cnf
    - ./db/initdb.d:/docker-entrypoint-initdb.d
    ports:
    - 3306:3306


  • db/initdb.d/01_structure.sql
CREATE DATABASE `mogemoge`;

MySQL, PostgreSQL 公式 Docker コンテナでは
/docker-entrypoint-initdb.d 以下の SQL を起動時に実行する為
そちらに DB mogemoge を作成するよう設定しました。

これより 各 DB にテーブルを作成していきます。

1. テーブル作成

テーブル定義ファイル作成

DB hogehogeusers テーブルを
DB mogemogemembers テーブルを
作成する Phinx 定義ファイルを作成します。

定義ファイルのクラス名はキャメル形式限定です。

$ make migrate_create DB=hogehoge CLASS=CreateTableUsers
$ make migrate_create DB=mogemoge CLASS=CreateTableMembers
...
...
created db/migrations/hogehoge/20170724065658_create_table_users.php
created db/migrations/mogemoge/20170724065738_create_table_members.php

db/migrations 内に各 DB 毎のディレクトリが作成され、その配下に Phinx 定義ファイルが作成されているのが確認できます。

テーブル定義ファイル編集

  • db/migrations/hogehoge/20170724065658_create_table_users.php
<?php

use Phinx\Migration\AbstractMigration;
use Phinx\Db\Adapter\MysqlAdapter;

class CreateTableUsers extends AbstractMigration
{
    public function up()
    {
        // 自動生成される id を排除し、primary key を user_id とする
        $t = $this->table('users', ['id' => 'user_id']);

        $t->addColumn('last_name',       'string',     ['limit' => 10,  'comment' => '姓'])        // string 型 20文字制限
          ->addColumn('first_name',      'string',     ['limit' => 10,  'comment' => '名'])        // string 型 20文字制限
          ->addColumn('last_kana_name',  'string',     ['null' => true, 'limit' => 10,  'comment' => '姓(カナ)']) // string 型 NULL許可 10文字制限
          ->addColumn('first_kana_name', 'string',     ['null' => true, 'limit' => 10,  'comment' => '名(カナ)']) // string 型 NULL許可 10文字制限
          ->addColumn('username',        'string',     ['limit' => 20,  'comment' => 'ユーザ名'])   // string 型 20文字制限
          ->addColumn('password',        'string',     ['limit' => 40,  'comment' => 'パスワード']) // string 型 40文字制限
          ->addColumn('email',           'string',     ['limit' => 100, 'comment' => 'Email'])    // string 型 100文字制限
          ->addColumn('postcode',        'string',     ['limit' => 10,  'comment' => '郵便番号'])   // string 型 10文字制限
          ->addColumn('birthday',        'date',       ['comment' => '誕生日'])                    // date 型
          ->addColumn('gender',          'integer',    ['limit' => MysqlAdapter::INT_TINY, 'comment' => '性別(1:男 2:女 3:その他)']) // tinyint 型
          ->addColumn('card_number',     'string',     ['null' => true, 'limit' => 20,  'comment' =>'クレジットカードNo'])  // string 型 20文字制限 NULL許可
          ->addColumn('description',     'string',       ['null' => true, 'limit' => 255, 'comment' =>'説明'])  // string 型 255文字制限 NULL許可
          ->addColumn('created',         'timestamp',  ['default' => 'CURRENT_TIMESTAMP'])        // timestamp 型 default: CURRENT_TIMESTAMP
          ->addColumn('updated',         'datetime',   ['null' => true])                          // datetime 型 NULL 許可
          ->addIndex(['username', 'email'],     ['unique' => true])                               // username, email にユニークキー設定
          ->create();
    }

    public function down()
    {
        $this->dropTable('users');
    }
}


  • db/migrations/mogemoge/20170724065738_create_table_members.php
<?php

use Phinx\Migration\AbstractMigration;

class CreateTableMembers extends AbstractMigration
{
    public function up()
    {
        $t = $this->table('members');
        $t->addColumn('member_code', 'string',    ['limit' => 20,  'comment' => '会員コード'])   // string 型 20文字制限
          ->addColumn('created',     'timestamp', ['default' => 'CURRENT_TIMESTAMP'])        // timestamp 型 default: CURRENT_TIMESTAMP
          ->addColumn('updated',     'datetime',  ['null' => true])                          // datetime 型 NULL 許可
          ->addIndex(['member_code'], ['unique' => true])                                    // member_code にユニークキー設定
          ->create();
    }

    public function down()
    {
        $this->dropTable('members');
    }
}

2. カラム追加

テーブル定義ファイル作成

DB hogehogeusers テーブルにカラムを追加したいと思います。

$ make migrate_create DB=hogehoge CLASS=AddTableUsersColumnsCity
...
...
created db/migrations/hogehoge/20170724065838_add_table_users_columns_city.php

テーブル定義ファイル編集

カラム postcode の後にカラム city 追加します。

<?php

use Phinx\Migration\AbstractMigration;

class AddTableUsersColumnsCity extends AbstractMigration
{
    public function up()
    {
        $t = $this->table('users');
        $t->addColumn('city', 'string', ['limit' => 10, 'comment' => '都市', 'after' => 'postcode'])
          ->update();
    }

    public function down()
    {
        $t = $this->table('users');
        $t->removeColumn('city')
          ->save();
    }
}

マイグレーション実施

$ make migrate

Point !

ちなみにマイグレーションの実行順序は
配置されているファイルの数字・アルファベット順です。
以下の様な仕様となっています。

Aogehoge
H001gehoge
H01gehoge
H0gehoge
H1gehoge
H2gehoge
Hogehoge

テーブル確認

http://192.168.35.102/ へアクセスすると
Adminer のログインページが表示されます。

f:id:kenzo0107:20170829132009p:plain

※ docker-compose.yml で定義されているログイン情報

Key Value
System MySQL
Server mysql
User root
Password rootpass
Database hogehoge

サーバ情報を入力しログインすると作成されたテーブルを確認することができます。

f:id:kenzo0107:20170829132531p:plain

users テーブルをクリックし詳細を確認します。

f:id:kenzo0107:20170829132633p:plain

問題なく定義通りに作成されたことがわかります。

では、DB mogemoge はどうでしょうか。

DB mogemoge にも members テーブルが作成されていることが確認できます。

f:id:kenzo0107:20170829133145p:plain

Point !

各 DB に phinxlog テーブルが作成されています。

マイグレーション実行状況のステータスを管理しています。

  • DB hogehoge.phinxlog

f:id:kenzo0107:20170829133614p:plain

  • DB mogemoge.phinxlog

f:id:kenzo0107:20170829133606p:plain

DB スキーマ毎に phinxlog テーブルを分けた理由としては
将来的に DB スキーマごとお引越しする、もしくは、ドロップするとういう時に
都合が良い為です。


また、以下の様に DB スキーマを指定しテーブル作成はできますが

$t = $this->table('hogehoge.users');
$t->addColumn(...
  ->create();

カラム追加時には以下の様に DB スキーマを指定した場合には実行できない為、
複数 DBスキーマの場合は 1つの phinxlog での管理は現実的でないと考えました。

$t = $this->table('hogehoge.users');
$t->addColumn('city', 'string', ['limit' => 10, 'comment' => '都市', 'after' => 'postcode'])
  ->update();

シード作成

シード定義ファイル作成

$ make seed_create DB=hogehoge CLASS=UserSeeder
$ make seed_create DB=mogemoge CLASS=MembersSeeder
...
...
created ./db/seeds/hogehoge/UsersSeeder.php
created ./db/seeds/mogemoge/MembersSeeder.php

シード定義ファイル編集

  • ./db/seeds/hogehoge/UsersSeeder.php
<?php

use Phinx\Seed\AbstractSeed;

class UsersSeeder extends AbstractSeed
{
    public function run()
    {
        $t = $this->table('users');
        $t->truncate();

        $genders = [1,2,3];

        $faker = Faker\Factory::create('ja_JP');
        $d = [];
        for ($i = 0; $i < 10; $i++) {
            $d[] = [
                'last_name'        => $faker->lastName(10),
                'first_name'       => $faker->firstName(10),
                'last_kana_name'   => $faker->lastKanaName(10),
                'first_kana_name'  => $faker->firstKanaName(10),
                'username'         => $faker->userName(20),
                'password'         => sha1($faker->password),
                'email'            => $faker->email,
                'postcode'         => $faker->postcode,
                'city'             => $faker->city,
                'birthday'         => $faker->date($format='Y-m-d',$max='now'),
                'gender'           => $faker->randomElement($genders),
                'card_number'      => $faker->creditCardNumber,
                'description'      => $faker->text(200),
                'created'          => date('Y-m-d H:i:s'),
                'updated'          => date('Y-m-d H:i:s'),
            ];
        }

        $this->insert('users', $d);
    }
}


  • ./db/seeds/hogehoge/MembersSeeder.php
<?php

use Phinx\Seed\AbstractSeed;

class MembersSeeder extends AbstractSeed
{
    public function run()
    {
        $t = $this->table('members');
        $t->truncate();

        $faker = Faker\Factory::create('ja_JP');
        $d = [];
        for ($i = 0; $i < 10; $i++) {
            $d[] = [
                'member_code'  => $faker->regexify('[0-9]{20}'),
                'created'   => date('Y-m-d H:i:s'),
                'updated'   => date('Y-m-d H:i:s'),
            ];
        }

        $this->insert('members', $d);
    }
}

Faker というライブラリを利用することで 日本人の名前や住所、正規表現を使ったデータを作成できます。

シード実行

$ make seed

無事データが登録されました。

f:id:kenzo0107:20170829175823p:plain

おまけ 1

ここで Phinx の seed のデータ INSERT 方法が非常に気になりました。

...
...
 -- insert('members')
    -> 0.0023s
 -- insert('members')
    -> 0.0016s
 -- insert('members')
    -> 0.0019s
 -- insert('members')
    -> 0.0022s
...
...

1件ずつ INSERT してる...?

本家 Phinx github のソースを確認してみました。

  • src/Phinx/Db/Table.php
    /**
     * Commit the pending data waiting for insertion.
     *
     * @return void
     */
    public function saveData()
    {
        foreach ($this->getData() as $row) {
            $this->getAdapter()->insert($this, $row);
        }
    }

データを foreach して 1件ずつ登録している!
なんて日だ!

数十件ならまだ良いですが
シードデータも増えてくると待ち時間が増えてくるのは宜しくない。

と言うことで
バルクインサートする様修正しプルリクした所無事マージされました*2

https://github.com/cakephp/phinx/pull/1148/files

おまけ 2

SQL を直接実行することも可能です。

    public function up()
    {
        $q = <<<EOF
CREATE TABLE `users` (
  `user_id` int(11) NOT NULL AUTO_INCREMENT,
  `last_name` varchar(10) NOT NULL COMMENT '姓',
  `first_name` varchar(10) NOT NULL COMMENT '名',
  `last_kana_name` varchar(10) DEFAULT NULL COMMENT '姓(カナ)',
  `first_kana_name` varchar(10) DEFAULT NULL COMMENT '名(カナ)',
  `username` varchar(20) NOT NULL COMMENT 'ユーザ名',
  `password` varchar(40) NOT NULL COMMENT 'パスワード',
  `email` varchar(100) NOT NULL COMMENT 'Email',
  `postcode` varchar(10) NOT NULL COMMENT '郵便番号',
  `birthday` date NOT NULL COMMENT '誕生日',
  `gender` tinyint(4) NOT NULL COMMENT '性別(1:男 2:女 3:その他)',
  `card_number` varchar(20) DEFAULT NULL COMMENT 'クレジットカードNo',
  `description` longtext COMMENT '説明',
  `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated` datetime DEFAULT NULL,
  PRIMARY KEY (`user_id`),
  UNIQUE KEY `username` (`username`,`email`)
) ENGINE=InnoDB AUTO_INCREMENT=101 DEFAULT CHARSET=utf8;
        $this->execute($q);
    }

Phinx のお作法に則らないパワープレイではありますが
結局頭の中で実クエリに変換している脳内工数を考えると
これもアリかなと♪

議論の分かれる所かと思います。

もう一踏ん張りな所

  • TINY INT(3) の様なタイプ指定ができない (?)
  • ユニーク制御しているカラムへの Faker でランダムデータ生成では Duplicate Error 発生の懸念あり (←Fakerの話)

よかった所

  • 様々なタイプの DB へ適合
  • Faker 利用で日本語対応のデータ生成可
  • 比較的学習コスト低

まとめ

あらゆる DB への適合し今後とも善玉マイグレーションツールとして
期待される Phinx、如何でしたでしょうか?

PostgreSQL, MSSQL も同じ定義ファイルからマイグレーション・シードが実行でき、
無事 Moby Dock の腸までデータが届くことを確認しております。

是非お試しください♪

ご参考になれば幸いです。

参照


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

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

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

■開発環境はこちら

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

*1:Rails 移行中

*2:1000 件程度のデータのシード実行では 6倍以上パフォーマンスが向上していることを確認しています。