Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Railsで作られた管理画面にVue.jsを導入した話

Studyplusのweb版を担当していた久保です。 最近はRailsを触ったりしています。

今回は社内向けの管理画面を作る際に、どうしても動的にDOMを操作する必要があったのでjQueryの代わりにVue.jsを導入してみました。

なぜVue.jsを選んだのか

  • Railsが生成したhtmlをテンプレートとして使うことができる
  • Rails5系以降であれば、webpackerを利用するだけで良いので導入が楽

導入方法

導入時の環境は以下

  • rails: 5.2.0
  • ruby: ruby 2.5.1p57
  • node: v8.11.2
  • yarn: 1.7.0
  • webpacker: v3.5.3

webpacker

gem 'webpacker'
$ bundle install
$ bundle exec rails webpacker:install

Vue.js

$ bundle exec rails webpacker:install:vue

Vue.jsを動かす

webpacker.ymlに記載されていますが、標準だと、app/javascript/packs配下のファイルがエントリーファイルです。

JavaScriptファイルの読み込み

例えば app/javascript/packs/hello-world.js とした場合、ヘルパーメソッドを使って簡単に読み込むことができます。

...
  <%= javascript_pack_tag 'hello-world', 'data-turbolinks-track': 'reload', defer: true %>
</head>
...

今回は app/javascript/components 配下にVue.jsのコンポーネントを何個か定義して、エントリーファイル側でimportする形で実装しました。

開発環境で動かす

bin/webpack-dev-server というファイルができており、これを使うと JavaScript を編集した際にビルドが走りブラウザが勝手にリロードされるようになります。

web: rails s -p 3000
client: sh -c 'rm -rf public/packs/* || true && bin/webpack-dev-server'

みたいなファイルを用意して、foreman を追加し

$ bundle exec foreman start -f Procfile.dev-server

とすると良いかもしれません。

本番環境

細かいチューニングが必要な場合は webpack の設定を弄るべきかもしれませんが、特に何もしなくても assets-precompile 時にいい感じになります。ここが本当に楽で素晴らしいと思っています。

Railsで生成したフォームの変化を検知して動的にDOMを操作する

Vue.jsを採用した理由として

Railsが生成したhtmlをテンプレートとして使うことができる

と書きましたが、簡単にその機能の紹介をします。

【サンプル】select要素の変更に応じてcss classの付け外しを行う

以下が設定の一部とマウント対象のerbです。

const ConversionType = {
    el: '#js-conversion_type',
    data: {
        form: {
            conversionType: document.getElementsByClassName('js-conversion_type')[0].value
        }
    },
    computed: {
        isRequired: function() {
            switch (this.form.conversionType) {
                case 'nop':
                    return {
                        title: false,
                        urlIOS: false,
                        urlAndroid: false,
                    };
                // 以下case文省略
            }
        }
    }
}

export default ConversionType;
  <div id="js-conversion_type">
    <div class="row mb-4">
      <div class="col-xs-3">
        <%= form.label :conversion_type %>
      </div>
      <div class="col-xs-9">
        <%= form.select :conversion_type, MessageDraft.enum_for_selectbox(:conversion_type), {}, class: 'form-control js-conversion_type', 'v-model' => 'form.conversionType' %>
      </div>
    </div>
    <div class="row mb-4">
      <div class="col-xs-3">
        <%= form.label :button_title, 'v-bind:class' => '{required: isRequired.title}' %>
      </div>
      <div class="col-xs-9">
        <%= form.text_field :button_title, id: :message_draft_button_title, class: 'form-control', 'v-bind:disabled' => '!isRequired.title' %>
      </div>
    </div>
    <div class="row mb-4">
      <div class="col-xs-3">
        <%= form.label :button_url_ios, 'v-bind:class' => '{required: isRequired.urlIOS}' %>
      </div>
      <div class="col-xs-9">
        <%= form.text_field :button_url_ios, id: :message_draft_button_url_ios, class: 'form-control', 'v-bind:disabled' => '!isRequired.urlIOS' %>
      </div>
    </div>
    <div class="row mb-4">
      <div class="col-xs-3">
        <%= form.label :button_url_android, 'v-bind:class' => '{required: isRequired.urlAndroid}' %>
      </div>
      <div class="col-xs-9">
        <%= form.text_field :button_url_android, id: :message_draft_button_url_android, class: 'form-control', 'v-bind:disabled' => '!isRequired.urlAndroid' %>
      </div>
    </div>
  </div>

ConversionType.jsの説明

el: '#js-conversion_type'

に関してですが、 公式ドキュメント を読むと

既存の DOM 要素に Vue インスタンスを与えます。

render 関数または template オプションも存在しない場合、マウントしている DOM 要素にある HTML がテンプレートとして抽出されます。

と書いてあります。この設定により、_form.html.erb 配下を Vue.js のテンプレートとして扱うことが可能になります。

data: {
    form: {
        conversionType: document.getElementsByClassName('js-conversion_type')[0].value
    }
},

ドキュメントは こちら です。 select要素の初期値が後述する v-model を用いた場合に設定されなかったため、値を設定しています。

select要素の変更に合わせて、class等を操作する

v-model ディレクティブ と、computed プロパティ を用います。

<%= form.select :conversion_type, MessageDraft.enum_for_selectbox(:conversion_type), {}, class: 'form-control js-conversion_type', 'v-model' => 'form.conversionType' %>

と設定するだけで、onChange 等を書かずに値の変更を検知できます。

更に computed プロパティを用いて form.conversionType の値に応じて各 input 要素が必要かどうかの bool値を持つオブジェクトが算出されるように設定します。

computed: {
    isRequired: function() {
        switch (this.form.conversionType) {
            case 'nop':
                return {
                    title: false,
                    urlIOS: false,
                    urlAndroid: false,
                };
            // 以下case文省略
        }
    }
}

テンプレート側からは

<div class="row mb-4">
  <div class="col-xs-3">
    <%# isRequired.title が true の場合は required クラスが付与される %>
    <%= form.label :button_title, 'v-bind:class' => '{required: isRequired.title}' %>
  </div>
  <div class="col-xs-9">
    <%# isRequired.title が false の場合は disabled 属性が付与される %>
    <%= form.text_field :button_title, id: :message_draft_button_title, class: 'form-control', 'v-bind:disabled' => '!isRequired.title' %>
  </div>
</div>

というふうに参照できます。

まとめ

Vue.js全く触ったことなかったのですが、あくまでRailsメインで画面を組んだ時に、補助としてVue.jsを使うのは導入と学習コストの点から悪くないのではという印象を受けました。

中高生国際Rubyプログラミングコンテスト2018への協賛

こんにちは、スタディプラスの島田です。

2018年7月15日(日)から応募受付が開始される中高生国際Rubyプログラミングコンテスト2018にて、スタディプラスはGold PARTNERとして協賛させていただきます。

f:id:studyplus:20180705150648p:plain

スタディプラスではこれまでもRubyKaigi 2018等のカンファレンスへの協賛をしてきましたが、その目的はおおまかに以下のようなものになります。

  • 技術カンファレンスへの貢献
  • メンバーの技術的な知見を広げる
  • スタディプラスの認知拡大

今回のプログラミングコンテストへの協賛は上記の理由とは異なり、スタディプラスのミッションや事業と親和性があり、なんらかの協力ができないかと考えたからです。

弊社のサービス「Studyplus」は多くの中高生の方に利用していただいており、またサーバーサイドはRubyを利用して開発しています。

コンテストに参加される中高生の方々にも、是非Rubyを通して、創ることの楽しさや、新たな可能性を発見をしてもらえればと思います。

nginxのX-Accel-Redirectを使った縮小画像配信サーバ

インフラまわりを担当しております。id:rmanzokuです。

今回は、画像配信サーバをnginxを使ってプチリプレースをしたので その実装方法を紹介します。

課題と対応

Studyplusでは、ユーザーが投稿した画像や教科書の表紙画像を任意のサイズに縮小し配信する機能があります。

この機能はリリース初期から存在し、Javaで実装されていました。 退職済みメンバーの個人リポジトリのライブラリに依存していることもあり、メンテナンスコストが非常に高くなっていました。

(皆さんもそういう経験ありますよね?)

リリース初期では、複数の機能を搭載したJavaアプリケーションでしたが現在では

  • 画像管理テーブルからリクエストされたIDに対応する画像ファイルのURLを取得する
  • そのファイルをクエリ文字列で指定した任意のサイズに縮小しユーザーへ返答する

という非常にシンプルな機能しか残っていませんでした。

今回、この画像配信機能のメンテナンスコストを下げるためリプレースを実施しました。

実装方針

前述の課題に対応するため、3パートに分けて進めました。

  1. 画像IDからDBアクセスし、画像ファイルのURLを取得するアプリケーションサーバ
  2. nginxによるX-Accel-Redirectを使った画像の取得
  3. nginxによるcubicdaiya/ngx_small_lightを使った画像リサイズ

Go言語の採用

画像IDからDBアクセスし、画像ファイルのURLを取得するアプリケーションにはGo言語を採用しました。

Go言語は豊富な標準ライブラリを備えており、今回の要件ではMySQLドライバ程度の外部ライブラリで実装できます。

実際に、100行以下の1ファイルで実装できました。 コードも見通しよくメンテしやすいアプリケーションになったと感じています。

X-Accel-Redirectとは

X-Accel-Redirectとは、nginxの内部リダイレクトを実行させるためのトリガーとなるヘッダです。

アプリケーションサーバから静的ファイルを返答する場合、ロジックのないファイルの返答のためにアプリケーションの処理が専有されてしまいます。 これを避けるために、アプリケーションサーバはX-Accel-Redirectにファイル名などを返しnginxがリダイレクトすることでアプリケーションの負荷を下げることができます。

この機能は、Rails(Rack)にも利用されており、Apache HTTP ServerではX-Sendfileヘッダとして知られています。

要件次第では、静的ファイルへのアクセス方法が一意に決まらず、DBアクセスや認証を必要とする場合があります。 アプリケーションサーバで処理をした結果、X-Accel-Redirectを有効活用することでアプリケーションとnginxの役割分担をすることが可能になります。

と、説明しましたが、nginx上の別のlocationにリダイレクトするためにも利用できます。 今回の実装では、X-Accel-Redirectは画像縮小のためのlocation /image_redirect/へのリダイレクトに利用し、X-Imagefileという独自ヘッダに画像URLを入れています。

具体的なフロー図

画像IDを1111、縮小後サイズを100x100としたときのフロー図です。

f:id:rmanzoku:20180628105537p:plain

画像ファイルURL取得サーバ

Go言語で実装した画像ファイルURLを取得するアプリケーションサーバです。

  1. URL.pathから画像IDを取得する
  2. 画像IDを使って画像管理テーブルから画像ファイルURLを取得する
  3. X-Accel-Redirectに内部リダイレクト先とクエリ文字列を入れる
  4. X-Imagefileに画像ファイルURLを入れる

旧実装では、画像の取得から圧縮まで行っていましたが、 本実装ではヘッダに値を入れているだけで、画像ファイルを扱う必要はありません。

以下にエラーやHTTP部分を除いて一部抜粋したコードを示します。

var db, _ = sql.Open("mysql", dsn)

func handler(w http.ResponseWriter, r *http.Request) {
    uri := r.URL.String()
    path := r.URL.Path

    var id int
    var accelRedirect string
    var imageFile string
    var err error

        // リクエストパスからIDを取得
        id, _ = strconv.Atoi(path[1:])

        // IDから画像ファイルURLを取得
    _ = db.QueryRow("SELECT filename FROM image_entries where id=?", id).Scan(&imageFile)

        // X-Accel-Redirectに内部リダイレクト先とクエリ文字列を入れる
    accelRedirect = "/image_redirect/?" + r.URL.RawQuery
    w.Header().Set("X-Accel-Redirect", accelRedirect)

        // X-Imagefileに画像ファイルURLを入れる
    w.Header().Set("X-Imagefile", imageFile)
}

nginx設定例

nginxでは、画像ファイルURLから渡されるヘッダに基づいてリダイレクトと画像縮小ができるように設定します。

次にserverディレクティブの設定例を示します。

server {
    listen 80;

    # クエリ文字列 `?w=100&h=100` は保持されたまま内部リダイレクトされる
    location /image_redirect/ {
        internal;

        set $path_to $upstream_http_x_imagefile;
        proxy_hide_header upstream_http_x_imagefile;
        proxy_pass $path_to;

        small_light on;
        small_light_getparam_mode on;
    }

    location / {
        proxy_pass http://ImageFetchGo;
    }
}

アプリケーションサーバImageFetchGoから ヘッダX-Accel-Redirect: /image_redirect/?w=100&h=100と返すことで、クエリ文字列を保持したまま内部リダイレクトが可能です。 また、$upstream_http_x_imagefileからヘッダX-Imagefile: https://image-storage/hogehoge.jpgの画像ファイルURLが取得できるため、proxy_passすることで画像を取得できます。

取得した画像はcubicdaiya/ngx_small_lightを利用してクエリ文字列に応じたサイズへ変換されます。 縮小された画像は、無事ユーザーへ届けられます。

まとめ

nginxのX-Accel-Redirectを利用した画像配信サーバのリプレースについて紹介しました。 普段馴染みのないヘッダですが、各機能の実装を最小限にし組み合わせることで、強力な力を発揮できます。

スタディプラスでは、ミドルウェアを活用してアプリケーション全体を効率化できるインフラエンジニアを募集しております! この記事に興味を持ってもらえるならぜひお話だけでも遊びにきてもらえればとおもいます。

合同筋トレ会を開催しました👍

先日こんな記事を投稿した花井(id:hiroyuki-hanai)です。 以下の部分がきっかけで、ファインディ株式会社様筋トレ部と弊社筋トレ部で合同筋トレを開催しました! 👏

筋トレっていいですよね。 💪 BIG3の総重量が280kg(5RM)になりました。サーバーサイドエンジニアの花井(id:hiroyuki-hanai)です。

弊社筋トレ部は、Androidエンジニアである渡辺さんが部長を務める社内部活動です。 平日の業務後に東京体育館で不定期に4人程度で活動しております。 (オリンピックに向けた建て替えにより、6月いっぱいでしばらくお休みになってしまうため、活動継続の危機に瀕しております。)

ファインディ株式会社様といえば、筋トレにちなんだユニークな企画をされていたので、トレーニーエンジニアの皆様はご存知のことと思います。

フリーウェイトを使ったトレーニングをすると予告してしまったため、弊社筋トレ部からは私以外誰も参加しませんでした。😢 ファインディ株式会社様筋トレ部からは、CTOの佐藤さんにお越しいただき、総勢2名で筋トレを楽しみました!

メニュー

この日は2人とも胸の日だったので、開幕ベンチプレスをキメようと思ったものの、長蛇の列… 仕方なくマシントレーニングで時間と大胸筋をを潰します。

ラットマシン

高重量を扱う際はに、広背筋への意識が欠かせませんよね。ということで、ラットマシンで体を温めます。

チェストプレス

今日は胸の日ですから、大胸筋のウォームアップをします。

ダンベルフライ・ダンベルプレス

東京体育館はマシンエリアと、フリーウェイトエリアが分かれており、予約待ちの間マシントレーニングをしていました。 前の人が早めに上がったらしく、別の人にベンチを使われてしまっておりました。 フリーウェイトエリアで待っている間手持ち無沙汰なので、ダンベルフライ・インクラインダンベルプレスで大胸筋にさらなる刺激を与えます。

ベンチプレス

さぁ、本日のメイン・トレーニングです。1人でやっているとなかなか追い込みきれないのですが、今回は佐藤さんの優しいサポートもあり、久しぶりに追い込むことができました!🤗 ドロップセットもこなして、大胸筋はバーンアウトです。

シットアップ

ストレッチをしながら、腹筋トレーニングの話題になったので、そのままシットアップに突入しました。

いつも以上に追い込めたので、とても充実したトレーニングができました。佐藤さんありがとうございました!

最後に記念撮影! f:id:hiroyuki-hanai:20180619153301j:plain

Railsフロントエンド設定をふつうにする

4月からForSchool事業部の石上です。以前は、主にStudyplusのウェブ版を担当していました。現在ForSchool事業部では、Studyplus for Schoolというプロダクトをエンジニア2人で開発しています。2人ともサーバーサイドとフロントエンドの両方を担当しています。

今回、Studyplus for Schoolのフロントエンド周りに必要なツールを入れるなどして、フロントエンドのコードをふつうに書いていくための下準備をしました。

背景

Studyplus for SchoolはRailsで作られたアプリケーションです。極力Railsのレールに乗るように作られています。

フロントエンドにはWebpacker、Babel、一部にReactをすでに利用していました。 多くの部分にはRailsのビューと、自前のベースJSコンポーネントを継承したコンポーネントが設置されています。

今後、アプリケーションのReact化と適切なコンポーネント分けを進めるために、下準備として型とLintとテストの導入に取り組みました。

私自身作業が遅いという問題はありますが、やはりこの辺のboilerplateを作ったことがない人であれば調べごとが多くなるのは避けられません。2日くらいでぺろっとやろうというよりは1週間以上はかけてやったほうがいいと思います。もしくは、一気に整えようとせずに徐々に環境を整えていくなりしたほうが良いです。今回は、ちゃんと時間をいただけて良かったです。

型があることで、

  • コンポーネントのpropsとstateが見やすくなる
  • エディタの補完を使えるようになる

などのメリットがあります。

JSに型を入れるツールとしては、TypeScriptとFlowが有名です。 今回はこの2つを比較して、結果としてTypeScriptを選択しました。

どちらもシンタックス的にそれほど大きな違いはありませんでしたが、使用方法や周辺環境に以下のような違いがありました。

Flowは静的型チェッカー、TypeScriptは言語

いずれもソースコードに型を書いていくのでJSへの変換が必要ですが、エラーの確認方法は違います。

Flowの場合、拡張子は.jsのままファイルの行頭に// @flowと書くことで型チェック対象とします。トランスパイル時のエラーにはなりません。型チェックするときはLintを走らせるように、flowコマンドを実行すればエラーが見れます。

一方、TypeScriptはJSのスーパーセットとはいえ、別言語です。拡張子は.tsにする慣習があり、ビルド時にコンパイルエラーが出ます。

DefinitelyTyped vs flow-typed

TypeScriptでコードを書いていて、なるべく外部ライブラリの型定義で困りたくはありません。なので、外部ライブラリの型定義ファイルを見つけられる可能性の高い方がいいです。

量だけで見ると、TypeScriptの型定義ファイルの置き場所であるDefinitelyTypedの方が多いです。今の所TypeScriptを使ったほうが困ることは少ないと推測しました。

なお、Studyplus for Schoolではreact-on-railsというライブラリを使っていて、こちらの型定義ファイルは存在しなかったので自前で書く必要がありました。他にも、いくつかの型定義ファイルは自分たちで書く必要が出てくるかもしれません。

Lint

TypeScriptのLintには、tslint が使えます。

tslint.jsontslint:recommendedを指定すると、TSLintのおすすめのルールが設定されます。

また、React用には、tslint-react というパッケージがtslintを開発しているpalantir社から公開されているので、それがそのまま使えます。

中身を見てみると、Reactのアンチパターン的な書き方は大体含まれているようです。

今回は tslint:recommendedtslint-react をベースに、カスタマイズしていく形にしました。

{
  "extends": ["tslint:recommended", "tslint-react"],
  "rules": {
    "arrow-parens": false,
    ...
  }
}

Lintがアプリケーションの改善につながるところ

Lint設定するにあたって、コードの書き方を定めて無駄な迷いをなくして生産性向上しようという意図がありました。Reactアプリケーションの場合はこれに加えて、書き方によってパフォーマンスに影響が出るようなところを見つけて改善することにも役立ちました。

たとえば以下のようなものです。

jsx-no-bind

Binds are forbidden in JSX attributes due to their rendering performance impact

このルールはtslint-reactのv2.6.0からのもので、以下のような書き方を禁止しています。

export Parent extends React.Component<Props, State> {
  onClickItem() {
    // ...
  }
  render() {
    return <Child onClick={this.onClickItem.bind(this)}>
  }
}

render時に毎回新しい関数をつくることになるので、パフォーマンスに影響があります。以下のように直したほうが良く、そうすることでLintも通るようになります。

export Parent extends React.Component<Props, State> {
-   onClickItem() { /*...*/ }
+   onClickItem = () => { /*...*/ }

  render() {
-   return <Child onClick={this.onClickItem.bind(this)}>
+   return <Child onClick={this.onClickItem}>
  }
}

Reactのドキュメントにも、注釈に書かれています。

Using Function.prototype.bind in render creates a new function each time the component renders, which may have performance implications (see below).

Passing Functions to Components - React

テスト

今回、Reactコンポーネントに対してユニットテストが行えるところまでを設定しておきました。

Reactコンポーネントでやりたいことは、ユーザーのアクションやイベントを受け取って状態を変えて、見た目に反映することです。なので、

  • イベントに対して意図した通りに状態が変わっていること
  • 入力に対して正しい見た目を出力していること

をテストすれば十分ではないでしょうか。これができれば、ライブラリは何でもいいと思います。

テストには、基本的なテストライブラリに加えて、Reactのコンポーネントを扱うためのライブラリが必要です。今回は、同様のライブラリの中でも設定が楽そうなJestEnzyme を選択しました。

Jest

Jestは、Facebookによって開発されている「ゼロ・コンフィギュレーションのテストプラットフォーム」です。楽に設定できるのが特徴なので、ここには実際どんな設定が必要だったのかを簡単に書いておきます。

インストール

$ yarn add -D jest @types/jest ts-jest

JSのソースをテストする分にはjestさえ入れればテストを書き始められます。今回はTypeScriptなので、jestの型定義ファイルと、プリプロセッサのts-jestを入れました。

TypeScript固有の設定については、ts-jestのREADMEの通りにすれば大丈夫です。Jestの設定をpackage.jsonに書き足します。

{
  "scripts": {
    "test": "jest"
  },
  "jest": {
    "transform": {
      "^.+\\.tsx?$": "ts-jest"
    },
    "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js",
      "jsx",
      "json",
      "node"
    ]
  }
}

これでテストが書けるようになりました。

Enzyme

Enzymeは、テストコードでReactコンポーネントを扱うために入れました。

インストール

$ yarn add -D enzyme enzyme-adapter-react-16

shallowmount でコンポーネントを初期化して、テストで行いたい操作ができます。

shallow では浅いレンダリングが行われます。最終的に出力されるDOM要素をすべて確認することはできませんが、そのコンポーネントの子のコンポーネントまでは確認することができます。

mount では子以下のコンポーネントも含めてすべてrenderするので、DOM要素を確認することができます。

ユニットテストとしてはなるべくmountは使わず、shallowを使う意識でテストを書いたほうが良いと思います。親のコンポーネントが子のコンポーネントが管理しているDOMを確認するより、子のコンポーネントのテストとして書いた方が、関心を分離できるからです。

// よい
const wrapper = shallow(<MyParentComponent hoge={hoge} />)
expect(wrapper.find("ChildComponent")).toHaveLength(1);

// よい
const wrapper = shallow(<MyChildComponent hoge={hoge} />);
expect(wrapper.find(".child-component__label")).text().toBe("ほげ")

// よくない
const wrapper = mount(<MyParentComponent hoge={hoge} />);
expect(wrapper.find(".child-component__label")).text().toBe("ほげ");

その他

Webpackerどうする問題

現在、Studyplus for SchoolではWebpackerを利用しています。しかしいくつか問題があり、これが正しい選択かはあまり自信がないため上では扱いませんでした。現状として、問題になった部分だけ設定を書き換えて使っています。たとえばUglifyJSがデフォルトの設定ではproductionでソースマップを吐いたり、同じくUglifyJSの設定が原因のIE11で起きる不具合を踏んだりなどしました。

とはいえ、やはりWebpackerを使うことで、Webpack設定に割く労力を節約できます。現段階で脱Webpackerは考えていません。

まとめ

今回、フロントエンド環境の下準備として、型とLintとテストの設定をしました。

今後は、以下のようなことをやっていきたいと考えています。

  • Reactで作られていないコンポーネントをReact化する
  • Railsのビュー(slim)に書かれた要素をフロントエンド側に持ってくる
  • Atomic Designによってコンポーネントを分ける
  • Storybookでコンポーネントごとの見た目確認を行えるようにする

ForSchool事業部では、フロントエンド以外にも、サーバーサイド、デザイン、カスタマーサポート、企画レベルでもそれぞれやりたいことはたくさんあります。手が足りてません。手伝ってくれるRailsエンジニアを募集しております。よろしくお願いします。

info.studyplus.co.jp

fastlaneでCode Signing StyleをAutomaticからManualに変更する

はじめに

Studyplus開発部のiOSエンジニアの id:kurotyann です。2015年に入社してもうすぐで3年になろうとしています。

今回の開発者ブログは、弊社のiOSアプリ「Studyplus」 のCI環境について紹介しながら、fastlaneでCode Signing Styleを変更する方法を説明します。

Studyplus iOSのCI環境

図で説明すると下記のような構成です。他にもツールを使用していますが、基本的な流れはこのような感じです。

CI環境の構成

Code Signing Styleについて

Code Signing Styleは、XcodeのプロジェクトのBuild Settingsから確認でき、AutomaticとManualの2種類を選択できます。Automaticの場合、Xcodeが適切なタイミングで開発に必要な証明書やProvisioning Profileの関係を解決してくれます。Manualの場合、開発者が任意の証明書やProvisioning Profileを指定することが可能になります。

XcodeのCode Signing Style

Code Signing StyleをAutomaticからManualに変更したい理由

Appleが推奨する設定は、Automaticです。しかし、AutomaticのままだとCI環境で意図したとおりに証明書やProvisioning Profileを参照してくれないことがあります。

またCIサービスが不調で動作しないとき、ローカルからfastlaneを起動したいことがたまにあります。そのときMacに複数の証明書やProvisioning Profileがあると、適切な証明書やProvisioning Profileを参照しないことが起きます。

このようなあらゆる事態に対応できるようにfastlaneで確実に任意の証明書やProvisioning Profileを指定させたい。Code Signing Styleのローカル環境とCI環境の切り替えをスムーズに管理したい、というのが今回のブログの主旨です。

ローカル環境(デフォルト設定)をAutomaticにしておき、fastlaneを使ってManualに変更するメリットは以下のようなものが考えられます。

  • fastlane matchで取得したProvisioning Profileを確実にManualで指定すると、ビルドエラーの可能性を減らせる
  • デフォルト設定はAutomaticなのでDebugで開発する場合、証明書を共有する必要がない
  • 外部から短期で開発に参加するエンジニアをApple Developerのmember権限でTeamに追加できる
    • デフォルト設定をManualにすると証明書の共有や権限の変更などが必要になる
    • adminは権限が大きすぎて短期で開発に参加する人の権限としてふさわしくはない

fastlaneでCode Signing Styleを変更する

必要な処理を書いたFastfileを書きます。

このFastfileは、Debugの証明書とProvisioning Profileをmatchで取得して、Code Signing StyleをManualに変更します。その後、Debugビルドでエクスポートしてcrashlyticsのbetaに送信します。

default_platform(:ios)

platform :ios do
  before_all do |lane, options|
    ENV["CRASHLYTICS_API_TOKEN"] = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    ENV["CRASHLYTICS_BUILD_SECRET"] = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    ENV["FASTLANE_USER"] = "test@example.com"
    ENV["FASTLANE_PASSWORD"] = "hogehoge"
    ENV["MATCH_PASSWORD"] = "fugafuga"
    ENV["MYAPP_ID_DEBUG"] = "com.mycompany.myapp"
    ENV["MYAPP_EXTENSION_ID_DEBUG"] = "com.mycompany.myapp.myappextension"

    if is_ci?
      setup_circle_ci
    else
      cocoapods
    end
    carthage(command: "bootstrap", platform: "iOS", use_binaries: false)
  end

  #####################################################
  ###### Private Lane                             #####
  #####################################################

  # 証明書の取得
  private_lane :certificate_development do |options|
    match(
      app_identifier: [ENV["MYAPP_ID_DEBUG"],
                       ENV["MYAPP_EXTENSION_ID_DEBUG"]],
      type: "development",
      force_for_new_devices: true,
      force: options[:force]
    )
  end

  # TodayExtensionなどターゲットが複数ある場合は各々指定して変更する
  private_lane :change_code_signing_style do |options|
    automatic_code_signing(
      targets: "MyApp",
      code_sign_identity: options[:code_sign_identity],
      profile_name: options[:my_app_profile_name],
      use_automatic_signing: options[:use_automatic_signing]
    )
    automatic_code_signing(
      targets: "MyAppExtension",
      code_sign_identity: options[:code_sign_identity],
      profile_name: options[:my_app_extension_profile_name],
      use_automatic_signing: options[:use_automatic_signing]
    )
  end

  #####################################################
  ###### Public Lane                              #####
  #####################################################

  desc "Push a new beta Debug build to Crashlytics"
  lane :beta_debug do
    certificate_development(force: false)
    # Maunalに変更
    change_code_signing_style(
      code_sign_identity: "iPhone Developer: my account (12345ABCDE)",
      my_app_profile_name: "match Development com.mycompany.myapp",
      my_app_extension_profile_name: "match Development com.mycompany.myapp.myappextension",
      use_automatic_signing: false
    )
    build_app(
      workspace: "MyApp.xcworkspace",
      scheme: "MyApp",
      configuration: "Debug",
      include_bitcode: false,
      output_directory: "./Builds",
      output_name: "MyApp_Debug.ipa",
      export_method: "development"
    )
    # Automaticに戻す
    change_code_signing_style(
      code_sign_identity: "iPhone Developer",
      my_app_profile_name: "",
      my_app_extension_profile_name: "",
      use_automatic_signing: true
    )
    crashlytics(
      crashlytics_path: "./Pods/Crashlytics/iOS/Crashlytics.framework",
      notes: "fastlaneによる配布",
      notifications: true,
      api_token: ENV["CRASHLYTICS_API_TOKEN"],
      build_secret: ENV["CRASHLYTICS_BUILD_SECRET"],
      ipa_path: "./fastlane/builds/MyApp_Debug.ipa",
      notes: changelog_from_git_commits,
      groups: ["my-ios-team"]
    )
  end
end

Code Signing StyleをAutomaticからManualに変更する処理は、 change_disable_automatic_code_signingautomatic_code_signing です。automatic_code_signing にはいくつかaliasがありますが、今回は use_automatic_signing: でCode Signing Styleを変更する方法を選びました。

このFastfileはDebugですが、ReleaseやAdhocも基本的には指定する値が変わるだけです。crashlytics betaからTestFlightなど使用するツールの変更はあると思いますが、処理の順番や呼ぶメソッドに大きな変更はないと思います。

おわりに

現在、StudyplusのiOSエンジニアは id:kurotyann だけです。つまり、iOSの開発は私一人で行なっています。このような限られたリソースと時間の中で、実装と検証のサイクルをスムーズに回すにはCI環境が必要不可欠です。

このブログがCode Signing Styleの切り替えに悩んでいるエンジニアの助けになると嬉しいです。また一緒に開発してくれる仲間が弊社に増えるともっと嬉しいです。

参考資料

AWS IoT Enterprise Buttonを使ってSlack通知ボタンを作る

こんにちは。スタディプラスでインフラ周りを担当している id:rmanzoku です。 先日、国内での発売が開始されたAWS IoT Enterprise Buttonを使ってオフィスの小さな改善を行ったのでご紹介します。

f:id:rmanzoku:20180522171742p:plain

弊社の課題

弊社のオフィスは、ビルの4Fと6Fに分かれており、6Fは会議室となっております。 6Fは「なんとなく自分が最後だ」、という人が施錠しSlackにて報告していました。

f:id:rmanzoku:20180522171736p:plain

(採用面接がある日は遅くなりがち)

この報告は忘れやすいですし、毎日行うことから非常に手間です。 今回、この報告をIoTボタンを使って簡略化することにしました。

AWS IoT Enterprise Buttonとは

先日、国内で発売されたIoTボタンです。 以前、Amazon Dashボタンを見て、ハックを試みたエンジニアの方は多いはずです。

このIoTボタンでは、なんと、リンクしたAWS Lambdaを呼び出すことができます!

実装

今回、解決したい課題は「施錠したときの報告を簡略化したい」でした。 ですので、シンプルにSlackへ通知するボタンを実装することにしました。

アクティベートの方法は公式ブログが詳しいです。

サンプルアプリケーションによると、次のようなJSONがLambda eventとして渡されます。

{
    "deviceInfo": {
        "deviceId": "GXXXXXXXXXXXXXXX",
        "type": "button",
        "remainingLife": 98.7,
        "attributes": {
            "projectName": "Sample-Project",
            "projectRegion": "us-west-2",
            "placementName": "Room-1",
            "deviceTemplateName": "lightButton"
        }
    },
    "deviceEvent": {
        "buttonClicked": {
            "clickType": "SINGLE",
            "reportedTime": 1521159287205
        }
    },
    "placementInfo": {
        "projectName": "Sample-Project",
        "placementName": "Room-1",
        "attributes": {
            "key1": "value1"
        },
        "devices": {
            "lightButton":"GXXXXXXXXXXXXXXX"
        }
    }
}

この値のうち、placementInfo内は、ある程度自由に入力できます。 また、IoT 1-Clickのコンソールを見ると、 placementNameにIoTボタンのある場所、attributesにカスタマイズ情報を入力する思想ということが推察できます。

f:id:rmanzoku:20180522171746p:plain

画像のようにAttributeを設定し、 次のLambdaスクリプトを実行するようにします。 (弊社はRubyの強い会社ですが、手慣れているPythonです)

import os
import json
import logging
import urllib.request
import urllib.parse


logger = logging.getLogger()
logger.setLevel(logging.INFO)


def lambda_handler(event, context):
    webhook_url = os.environ.get('WEBHOOK_URL')

    logger.info('Received event: ' + json.dumps(event))

    text = '''
    %s %s
    ''' % (
        event["placementInfo"]["placementName"],
        event["placementInfo"]["attributes"].get("msg")
    )

    body = {
        "link_names": 1,
        'username': event["placementInfo"]["attributes"].get("username", "AWS IoT"),
        'text': text,
        'icon_emoji': event["placementInfo"]["attributes"].get("icon_emoji", ":aws:")
    }

    encoded_post_data = urllib.parse.urlencode({"payload": body}).encode(encoding='ascii')
    urllib.request.urlopen(url=webhook_url, data=encoded_post_data)

内容は至ってシンプルなSlack Incoming webhookのスクリプトです。GitHub

WEBHOOK_URLはLambdaの環境変数から指定することにし、 SlackのusernameとiconはAttributeで渡すようにしました。

結果

IoTボタンを施錠記名簿のところに置くことで簡単にSlackへ施錠通知ができるようになりました。

f:id:rmanzoku:20180522171739p:plain

イケているオフィスや自宅などではある程度自動化が可能かもしれませんが、弊社オフィスのように自動化できない状況は多いと思います。 なにか1つだけでも改善することで、働きやすいオフィスに近づいていけると思います。

今回の例では、改善案を思いついて1時間くらいで実装が終わりました。 小さな改善でもすぐに実行して結果が出せるのはエンジニアの楽しみの1つだと思いました。