Studyplus Engineering Blog

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

FirebaseでSlack Slash Commandsを楽に運用する

ForSchool事業部開発グループの松田です。

弊社ではSlackを導入しており、いわゆるChatOpsに活用しています。
Slackにはさまざまな便利機能がついていますが、その中の一つにSlash Commandsというものがあります。
今回はこのSlash Commandsを自作して、そのバックエンドにFirebaseを利用した例をご紹介します。

Slack Slash Commandsとは

Slack内で /command のようにスラッシュから始まるコマンドを発言することで、特定のエンドポイントにリクエストを送信する機能です。
実装次第では /command sugoi argument などのように引数をとることもできるので汎用性が高いです。
標準機能として /remind/invite など複数のコマンドが定義されています。
Built-in slash commands – Slack Help Center

Firebaseとは

ここのところ流行っている感の強いモバイルプラットフォームです。
Googleの展開しているプラットフォームで、最近では国内の導入事例も見かけるようになりました。
認証周りをやってくれるAuthenticationや、ファイルホスティングのCloud Storageなどいくつかのサービスがありますが、
今回はJSの関数をさまざまなトリガーで実行できるCloud Functionsと、DBを提供するFirestoreを利用して簡単なコマンドを実装してみました。

実際に作ってみる

今回は弊社のボードゲーム部で使用する /boardgame コマンドを実装してみます。
仕様は /boardgame search players 4 の入力からDBを検索しプレイ人数に合致するボードゲームを返すコマンドとします。
Slash Commandsはコマンドが発行されると設定したURLにリクエストを投げるので、その先をCloud Functionsで受ける感じでやっていきます。

Slack AppとFirebaseプロジェクトを新規登録

Slack AppとFirebaseのプロジェクトを新規に登録します。
名前などいい感じにしましょう。

Cloud Functionsを仮デプロイ

firebase/firebase-toolsを使って、ローカルにFirebaseのプロジェクトと連携するフォルダを作ります。
初回設定時に使用するサービスを選択しますが、前述の通りFunctionsとFirestoreを選択します。
f:id:matsudap:20180817154806p:plain Functionsを選択するとJSとTSのどちらで書くかを聞かれますが、なんとなくTSにしました。
この時点で ./functions/src/index.tshelloWorld というHTTPリクエストがトリガーの関数が定義されているので、 boardgame に改名して、以下のコマンドを実行します。

$ firebase deploy --only functions

すると、デプロイ結果としてトリガーになるURLが表示されるので、ブラウザ等でリクエストをして Hello, World! と表示されればここまでは完了です。

自作Appをワークスペースに追加する

先程作ったSlack Appの設定メニュー「Slash Commands」からコマンドを登録します。
Request URLには先ほどデプロイしたCloud Functionsに割り当てられたURLを設定しておきます。
あとはよしなに設定します。
f:id:matsudap:20180817154828p:plain

Slash Commandから呼ばれる関数を実装する

とりあえず手元にボードゲームのリストを用意しました。

{
  "title": {
    "ja": "アグリコラ",
    "default": "Agricola"
  },
  "minPlayers": 1,
  "maxPlayers": 5,
  "minPlayingTime": 30,
  "maxPlayingTime": 150,
  "properAge": 12
},
{
  "title": {
    "ja": "デッドオブウィンター",
    "default": "Dead of Winter: A Crossroads Game"
  },
  "minPlayers": 2,
  "maxPlayers": 5,
  "minPlayingTime": 60,
  "maxPlayingTime": 120,
  "properAge": 13
},
...

これを適当なスクリプトを書いてFirestoreに突っ込みました。
最近使えるようになった配列の array-contains オペレータを使ってみたかったので、
Firestoreのトリガーで、minPlayersとmaxPlayersを下限と上限にした数値の配列をplayersフィールドに追加するようにしています。

そして実装ですが、公式のチュートリアルを参考にざっくりと以下のようになりました。1

import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin'

admin.initializeApp()
const db = admin.firestore()

class RequestError extends Error {
  code: number;
}

const commands = ['search'];

const searchFunction = (args) => {
  return new Promise((resolve, reject) => {
  })
}

export const boardgame = functions.https.onRequest((req, res) => {
  return Promise.resolve()
    .then(() => {
      if (req.method !== 'POST') {
        const error = new RequestError('Only POST requests are accepted');
        error.code = 405;
        throw error;
      }
      if (!req.body || req.body.token !== functions.config().slack.token) {
        console.log(req.body, functions.config().slack.token);
        const error = new RequestError('Invalid credentials');
        error.code = 401;
        throw error;
      }
      const [command, ...args] = req.body.text.split(' ');
      if (command == 'search') {
        let query:FirebaseFirestore.Query | FirebaseFirestore.CollectionReference = db.collection('games')
        if (args.includes('players')) {
          const playersIndex = args.indexOf('players') + 1;
          const players = args[playersIndex];
          query = query.where('players', 'array-contains', parseInt(players));
        }
        query.limit(3).get().then((querySnapshot) => {
          resolve({
            text: 'Boardgame Search Result',
            attachments: querySnapshot.docs.map((doc) => {
              const game = doc.data();
              return {
                title: game.title.ja,
                text: game.title.default,
                fields: [
                  {
                    title: 'Players',
                    value: `${game.minPlayers} ~ ${game.maxPlayers}`,
                    short: true,
                  },
                  {
                    title: 'Time',
                    value: `${game.minPlayingTime} ~ ${game.maxPlayingTime}`,
                    short: true,
                  },
                  {
                    title: 'Age',
                    value: game.properAge,
                    short: true,
                  }
                ]
              }
            }),
          });
        })
      } else {
        return new Promise((resolve, reject) => {
          resolve({
            text: 'Unknown command!',
            attachments: [],
          })
        })
      }
    })
});

functions.config().slack.token にはSlack App Basic Information内のVerification Tokenを予め設定しておきます。

Cloud Functionsにデプロイする

再度を以下のコマンドを実行してCloud Functionsにデプロイします。

$ firebase deploy --only functions:boardgame

Slach Commandを実行する

Slack Appを追加したワークスペースのチャンネル上で以下を発言します。

/boardgame search players 1

f:id:matsudap:20180817154858p:plain

便利ですね。

まとめ

Firebaseは運用コストが低いので、こういったピンポイントなAppのバックエンドとしては最適だと思いました。
FirebaseのいろいろなサービスもCloud Functionsからスッと使えるので対応できる幅も広そうです。
一方で、今回使用したCloud FunctionsとFirestoreは(2018/08/20現在)ベータでの提供なので留意してください!

また、弊社では就業後にボードゲーム部が不定期活動中です!
ボードゲームがしたい、Firebaseで業務改善したいエンジニアをどしどし募集しております〜。
まずはお気軽にオフィスに(ボードゲームを)遊びに来てください〜。
採用情報 | スタディプラス株式会社

www.wantedly.com


  1. このチュートリアルはFirebaseではなくGoogle Cloudのものなので、設定値の取得方法など微妙に差異があるので注意してください。

スタディプラス のAndroid事情

https://s3-ap-northeast-1.amazonaws.com/esa.s3.prod.lithium.studylog.jp/uploads/production/attachments/8682/2018/07/30/34447/a298aca0-1cc6-4021-aa28-f6f3bb44beeb.png

スタディプラス のAndroid事情

今回はAndroidアプリ担当エンジニアのNabesukeです。
組み込みエンジニア→Android→放浪→iOS→バックエンド→フロントエンド→Androidというよくわからない経歴を持っています。
今回はStudyplus Android版の裏側をご紹介します。TOP画像はこの前のスタディプラス LT大会2018(夏) - Studyplus Engineering Blogで使ったもの。

Android版の現状

Kotlin化を進めているが未だJavaが8割以上

image.png (8.9 kB)

目下Kotlin化( + ゆるいDatabinding)を進めていますが記事執筆時点でJavaは8割以上残っています。
(入社時は9割5分くらいがJavaでした。あんまり覚えてない)

Studyplusは画面が多いのでその分クラス数も多いです(1000ファイル以上あります)。
したがってヒマを見つけてはKotlin化に励んでいますがまだまだ終わりは見えていません。

MVCで作られています

比較的ファイル分割もされており、見やすく作られているので、学習コストも少ない分見やすいです。

使っているライブラリ

  • RxJava2
  • Retrofit2
  • Okhttp3
  • Glide
  • Orma(Roomに置き換えます)
  • fabric

その他広告SDKやらプッシュ通知など

クラスのpackage分けについて

これは開発者の好みとか諸事情がたくさん絡んでくるのでなんとも言えませんが、現状は「ふるまい」毎に分けられています。adapter/dialog/view/などなど。app直下にactivity達が大集合しています。
数えたらapp直下に160オーバーのActivityがありました。これは絶賛整理中なので、最初はもっとたくさんのactivity達がひしめいていました。

いま、どういうことをしているか

いまのところ私一人で開発しているので、ひとりでがんばってるなー程度に見ていただけたら幸いです。

packageの整理

私は機能単位でファイルを分けたい方なのでActivityやFragmentなどのPresentation層部分は機能別で振り分けています。(messageパッケージの中にメッセージ画面と関連するactivityやfragment、adapterなどをまとめる)
こうすると比較的関連activity/fragmentなどのクラスを探しやすくなるメリットがあります。modelやextentionなど、機能横断で使うものは現行そのままにしています。

Kotlin化

image.png (5.1 kB)

常に最優先で進めています。 いまのところ 「ぬくもりあふれる手作業」 で置き換えています。

ファイル数でいうと

  • java 719 file
  • kotlin 178 file(頑張った!)

その他xml多数

となっております。

なんでKotlin化するか?

ざっくり言ってしまうとKotlinが好きだから。これが一番大事だと思います。Kotlinかわいい。 過去、Androidアプリを開発した方で心がくじけた方もKotlinの書きやすさを見たら戻ってきたくなります(Android StudioやAndroid自体の良さもありますが)。自分もKotlinと会わなかったらAndroidに戻ってこなかったです。

大体以下の理由になります。主観が多分に混じっているので見識者はツッコミお願いします。

  • Googleが公式言語として認めている。
  • 割とコードを短縮できる(体感4/5くらいにはなる気がする)
  • スコープ関数や拡張関数、配列に対しての処理が便利。
  • Null safe。javaにもあるが、まだイマイチ使いにくい印象。
  • コルーチン(のasync/await)が使いたい。

MVCからゆるいMVVMへ

MVCで見やすいとは言いましたが、これのデメリットはよく言われるようにFatActivityになりがちです。
今後の機能追加で肥大化する可能性が大いにあります。
よって、MVVMに移行していますが機能追加との兼ね合いで進捗は芳しくありません。

AACを使ってActivityやFragmentから処理をひっぺがす

今は通信処理や、状態の更新処理などがActivityに書かれているので一旦それをViewModelに切り出します(いずれはViewModelからも引き剥がす)。 AACのViewModelはActivityのライフサイクルに合わせてデータをよしなに管理してくれるのでちょっと気持ちが楽になります。楽になりたい。
尚、Layout.xml側にViewModelをbindさせるかどうかは絶賛考え中です。ものによりますが、CustomAdapterとか定義すると途端にコードが追いにくくなる印象。

テスト

リリースの度に胃が痛くなる原因なんですが、いまのところテストがありません。
ぬくもりあふれる手作業でテストしてます。テスト導入したい。入社してから思ってるけど未だ着手の気配すらない。他にやること多すぎるという言い訳をさせてください。

CIツール

Bitriseを使いはじめました。いまのところBranch毎の各Build Variant向けビルドとfabricへのアップロード、play storeへのアルファ版リリースを自動化してる程度です。

おわりに

ぬくもりあふれる手作業が多い開発ですが、 これを書いている間にKotlinが2割を超えました。

image.png (16.0 kB)

私が入ってから5ヶ月。Convert to Kotlin してコードを修正し、ButterKnifeを取り除きDatabindingに移行するという作業を延々と手作業で繰り返しました。この調子で頑張りたいと思います。

「自分だったらもっとうまくやれるぜ!」や「ここをもっとこうしたら良くなるのにな」と思った方、弊社でお待ちしています

AWS Glueを用いたデータ分析基盤を構築した✨

こんにちは。業務委託の@morix1500と申します。

この度、スタディプラス様からデータ分析基盤の構築の業務委託を受け、AWSのマネージドサービスを用いて構築を行いました。
その際に得られた知見を共有したいと思います。

データ分析基盤について

今回スタディプラス様から受けたデータ分析基盤の要件は以下のようなものでした。

  • S3にあるログをAWS Athenaから閲覧できるようにしてほしい
  • S3にあるJSON形式のログを列指向型のフォーマット(Parquet)に変換してほしい
  • ログは順次取り込み(毎朝、昨日分のログが見れるようにする)

すでにログはS3にあったのでログ収集は終わっています。

データ分析基盤の構成

今回作成したデータ分析基盤はAWSのマネージドサービスで完結してます。
今回構築したのはGlueの部分です。

f:id:mori_morix:20180730165858p:plain

Glueの構成や初期構築の手順は以下のドキュメント通りです。 https://aws.amazon.com/jp/blogs/news/build-a-data-lake-foundation-with-aws-glue-and-amazon-s3/

構築時のTIPS

S3のパーティション分け

Amazon Athena のパフォーマンスチューニング にも記載されていますが、
データをパーティション分けするとパフォーマンスが上がります。

今回取り込み対象のS3のログのパスは以下のようにうまいことパーティション分けされていました。

s3://{BucketName}/2018/07/30/12/hoge.log.gz

この場合は

でパーティション分けされています。

Glueでこの形式のログをロードすると「partition_0」という名前でパーティションをAthenaなどで参照できるようになります。

Glueジョブの対象データの絞り込み

今回行いたいJSONからParquetに変換作業は、Glueの「ETLジョブ」を使用します。
コンソールでポチポチやるといい感じの変換用Pythonスクリプトを出力してくれますが、
デフォルトだと対象のS3パスのデータを毎回全件取り込んでしまいます。

「ログは順次取り込み」という要件があるので、なんとかジョブ対象のデータを絞り込みたいです。

その方法は、上記で作成したパーティションを利用します。
作成したパーティションはGlueのETLジョブでも参照できますので、ジョブ実行時パーティションで絞り込みを行います。
この機能のことを「Pre-Filtering」というそうです。
https://docs.aws.amazon.com/glue/latest/dg/aws-glue-programming-etl-partitions.html

# ソースを一部抜粋

# 1時間前の時間を指定
one_hour_ago = datetime.today() - timedelta(hours = 1)
year  = one_hour_ago.year
month = '{0:02d}'.format(one_hour_ago.month)
day   = '{0:02d}'.format(one_hour_ago.day)
hour  = '{0:02d}'.format(one_hour_ago.hour)
pushDownPredicateString = "(partition_0='{0}' and partition_1='{1}' and partition_2='{2}' and partition_3='{3}')"
pushDownPredicateString = pushDownPredicateString.format(year, month, day, hour)

datasource0 = glueContext.create_dynamic_frame.from_catalog(
    database = "Glue DB名",
    table_name = "テーブル名",
    push_down_predicate = pushDownPredicateString,
    transformation_ctx = "datasource0")

変換後のデータをS3に出力する際パーティション作成

GlueのETLジョブでは、変換後のデータをS3に保存できます。
その際にS3のパーティションを分けないと、Athenaでの参照時にパフォーマンスが上がりません。

こちらの設定もPythonで設定できます。
"year"や"month"と指定していますが、その前のマッピングで
partition_0 と year を関連付けないと使えませんので注意してください。

# ソースを一部抜粋

datasink4 = glueContext.write_dynamic_frame.from_options(
    frame = dropnullfields3, connection_type = "s3",
    connection_options = {
        "path": "s3://{Bucket Name}/",
        "partitionKeys": ['year','month','day','hour']
    },
    format = "parquet",
    transformation_ctx = "datasink4"
)

この設定を行うと以下のようなログが出力されます。

s3://{BucketName}/year=2018/month=07/day=30/hour=12/xxxxxxxxxxxxx.parquet

ログのカラムの増減時の対応

Glueはよく出来ていて、カラムが増えても動作に影響がありません。
ログのカラムが変更されると、CrawlersのTables Updatedが「1」になります。
f:id:mori_morix:20180730165904p:plain

カラムが増えたら、ETLジョブのマッピングのところに追加されたカラムの情報を追加。
カラムが減ったら特になにもする必要はないですが、ETLジョブのマッピングにはその情報は不要なので削除してもよいでしょう。

最後に

このようにログをデータ分析で使える形式にするためにやらなきゃいけないことが
AWSのマネージドサービスですべて済んでしまいました。

そして導入は非常に簡単です。
同じ課題をお持ちの方はこの記事を参考にデータ分析基盤を構築してみてはいかがでしょうか!

また宣伝になりますが、私@morix1500はこのような基盤構築やその他クラウド系のインフラの仕事を副業としてやらせていただいています。
なにかございましたらTwitterのDMなどでぜひ!

スタディプラス LT大会2018(夏)

こんにちは、CTOの島田です。
今回は7/13(金)に行われた社内LT大会についてです。(平成最後の13日の金曜日!?)

LT大会とは?

  • 趣旨
    • 日ごろの勉強・活動・興味関心を共有し、相互理解を促進する
  • 内容
    • 業務内外問わず、自身が技術を用いて取り組んだものを発表
  • ルール
    • 1人5分の持ち時間
    • CTO、外部審査員による審査により、賞を決定し、受賞者に賞品を贈呈
      • 外部審査員にリブセンス創業メンバーの桂大介氏を招待

発表内容一覧

タイトル 概要
AlexaとサーバーレスでつくるVUI勉強記録サービスのプロトタイプ Echo Dot, Lambda, DynamoDB, API Gateway, React を利用して、VUI勉強記録サービスのプロトタイプを作成、その発表をしました。
Graal Graal VM
暗号通貨の裁定取引ボットを作った Node.jsで暗号通貨の裁定取引ボットを作り、お金を稼ぎました
テックブログ考察 各社のテックブログをクロールして分析
iOSDC2018草稿 iOSDC2018で発表する内容を少し話しました
Webアプリの脆弱性スキャン OWASP ZAP を使ってStudyplus for Schoolの脆弱性検査をしました。
弊社の組織課題に関するご提案 ボードゲームサークル向けwebサービスをNuxtとFirebaseで作り始めました
入門 DAapps & Web 3.0 ブロックチェーンを基盤にしたアプリケーションDAppsとWeb 3.0について発表しました。
スタプラAndroid Studyplus for Androidで驚いたことと現状。

発表会の風景

結果

  • 桂賞: 「弊社の組織課題に関するご提案」
    • 寸評:スライドの構成と持ち時間ピッタリの発表内容。実装して動作する環境を用意した点。
  • CTO賞: 「AlexaとサーバーレスでつくるVUI勉強記録サービスのプロトタイプ」
    • 寸評:スマートスピーカとStudyplusのプロダクトを絡めた着眼点と、プロトタイプを実装した点。

受賞作品の紹介

speakerdeck.com

賞品

受賞者には目録が与えられ、それぞれの希望の賞品が贈呈されました。

  • 桂賞:ボードゲーム3万円分

  • CTO賞:Kindle Oasis

最後に

  • こういったLT大会のような場を定期的に持つ事で、メンバーにとって日頃の過ごし方の中にアウトプットの意識が根付くと感じました。
  • 発表内容もそれぞれユニークで、日々の取り組みでは触れる事のない内容もあり、良いインプットの場になったと思います。
  • ボードゲームに興味のある方、スタディプラス で一緒にボードゲームをしませんか?
  • 次回は半年後の1月に実施を予定。その際にはさらにメンバーが増えているかも!?

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

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