Studyplus Engineering Blog

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

第11回 Rails Girls Tokyoに参加して

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

スタディプラスは2月22・23日に開催された第11回 Rails Girls Tokyoへスポンサーと、コーチとして島田・冨山の2名が参加しました。

f:id:yo-shimada:20190227112152j:plain

Railsgirlsとは

RailsgirlsとはWebプログラミングに興味・関心はあるがプログラミング未経験の女性を想定とした、初めてプログラミングを書く人や、はじめてRailsを使う人を対象としたワークショップです。
現役のエンジニアをコーチとして、Railsを使ってWebアプリを作成し、Web上に公開するという内容です。

なぜスポンサー

スタディプラスでは第10回 Rails Girls Tokyo にもスポンサーをさせていただき、2回目のスポンサーとなります。 スポンサーをした目的は、

  • StudyplusというプロダクトのサーバーサイドはRubyを利用している事もあり、Rubyコミュニティへ貢献したい
  • RailsGirlsの趣旨と弊社の「学ぶ喜びを全ての人へ」というミッションに通じるところがあり、何か協力がしたい

といった事があります。

f:id:yo-shimada:20190227112621j:plain
スポンサーのノベルティ

このイベントに併せて、ステッカーを作りました。

なぜコーチ

今回、スポンサーの他に個人的にコーチとして参加したのは

  • 第10回でスポンサーLTをさせていただいた際に、会場の雰囲気を見て楽しそうだなと感じた
  • 弊社のデザイナーが第10回にgirlsとして参加し、楽しかったという感想を持った

という事をキッカケにコーチへ興味を持ち、自分自身も協力してみたいという思いからコーチとして参加しました。

感想

コーチとして参加した2人の感想

島田

普段自分が素通りしている事を改めて人に伝える事の難しさを感じましたが、誰かに教えるという体験を通じて自分自身のスキルアップにつながったと思っています。
教える事で感謝されたり、自分の説明が理解されたりする経験は得難いものがあると感じました。

f:id:yo-shimada:20190227112916j:plain

冨山

今回はじめてコーチとして参加してまいりました。周りのコーチのかた、またガールズのかたとワイワイrailsを書くというのは大変楽しい体験でした。
Railsを書いていくなかで自分も勉強させてもらうこともあり、完成したときにはこちらも達成感を覚えました。
普段こんなに1日中話すことはあまりなかったので体力的には疲れましたが、ワークショップ後のアフターパーティーで色々な方と交流もでき楽しく過ごすことができました。
今回のイベントを通してRubyを使うだけではなくRubyコミュニティを今後も盛り上げて行くために今後も何かと貢献できたらと思いました。

f:id:yo-shimada:20190227112945j:plain

最後に

今後もスタディプラスではRails Girlsをもちろん、Rubyコミュニティへの貢献ができればと考えています。

AWS Lambda上でnode-canvasを使ってグラフを描画する

ForSchool事業部でStudyplus for Schoolのサーバーサイドを担当している松田です。

Studyplus for Schoolでは、一部でChart.jsを利用したグラフの表示をしています。
Chart.jsはHTMLのCanvasでグラフを描画するライブラリです。
今回はこのグラフをサーバーで出力したくなったので、どうしたかを書いてみたいと思います。

はじめに

まず最初にサーバーサイドはRailsを使っているのでRubyを利用した出力を考えましたが、フロントと同様の見た目にしたいのでどうにかChart.jsをサーバーサイドで使いたいです。
サーバーサイドJSといえばNode.jsですが、Node.js上にはCanvasのAPIは用意されていません。
が、SeanSobey/ChartjsNodeCanvasを利用することで、Node.jsでChart.jsがレンダリングできます。
このライブラリは内部で Automattic/node-canvas というCanvas APIをcairoで再実装したライブラリを利用しています。
オンデマンドな環境で利用したかったので、Lambda上でこのライブラリを使ったグラフのレンダリングを試してみます。

インフラまわり

今回は外部からHTTPで気軽に扱えるよう、Lambdaプロキシ統合に設定したAPI GatewayとLambdaを併せて利用することを想定しています。
レンダリングした画像をS3にアップロードしたいので、LambdaにS3へのアクセス権限も付与しました。
Node.jsのランタイムは8.10です。

ビルド

Lambdaはその特徴上、依存ライブラリも含めたビルド済みのコード群をzipで圧縮したものをアップロードする必要があります。
先述の通りnode-canvasはcairoを利用しているので、Lambda上で動作するようにcairoをビルドした上で、そのバイナリをzipに含ませなければなりません。
Lambdaの実行環境はLambda 実行環境と利用できるライブラリに記載があるようにAmazon Linuxのようなので、Amazon Linuxのdocker imageを使ってビルドをすればよさそうです。
以下のコードはNot working on AWS Lambda · Issue #1231 · Automattic/node-canvasで紹介されているこちらのgistを参考にしました。

# Dockerfile

FROM amazonlinux:latest

RUN curl --silent --location https://rpm.nodesource.com/setup_8.x | bash -
RUN yum install -y nodejs zip
RUN npm install -g yarn

RUN mkdir /test
COPY ./package.json /test/
COPY ./yarn.lock /test/

WORKDIR /test

ENTRYPOINT ["yarn"]
CMD ["install"]
// package.json

{
  "name": "lambda-chartjs",
  "version": "1.0.0",
  "dependencies": {
    "canvas": "^2.3.1",
    "chart.js": "^2.7.3",
    "chartjs-node-canvas": "^2.0.0"
  },
  "scripts": {
    "prebuild": "docker build -t lambda-build .",
    "build": "docker run -v $(pwd)/node_modules:/test/node_modules lambda-build"
  }
}
// handler.js

'use strict';

const { CanvasRenderService } = require('chartjs-node-canvas');
const AWS = require('aws-sdk');
const s3 = new AWS.S3();

exports.handler = async function(event, context, callback) {
  try {
    const renderService = new CanvasRenderService(800, 600);
    const options = {
      type: 'bar',
      data: {
        labels: ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"],
        datasets: [{
          label: '# of Votes',
          data: [12, 19, 3, 5, 2, 3],
          backgroundColor: [
            'rgba(255, 99, 132, 0.2)',
            'rgba(54, 162, 235, 0.2)',
            'rgba(255, 206, 86, 0.2)',
            'rgba(75, 192, 192, 0.2)',
            'rgba(153, 102, 255, 0.2)',
            'rgba(255, 159, 64, 0.2)'
          ],
          borderColor: [
            'rgba(255,99,132,1)',
            'rgba(54, 162, 235, 1)',
            'rgba(255, 206, 86, 1)',
            'rgba(75, 192, 192, 1)',
            'rgba(153, 102, 255, 1)',
            'rgba(255, 159, 64, 1)'
          ],
          borderWidth: 1
        }]
      },
      options: {
        scales: {
          yAxes: [{
            ticks: {
              beginAtZero: true
            }
          }]
        }
      }
    };
    const buffer = await renderService.renderToBuffer(options)
    const params = {
      Bucket: '******',
      Key: 'hoge.png',
      Body: buffer,
      ContentType: 'image/png',
      ACL: 'public-read',
    };
    const data = await s3.upload(params).promise();
    callback(null, {
      statusCode: 200,
      body: JSON.stringify({ url: data.Location }),
    }); 
  } catch (e) {
    console.log(e);
    callback(null, {
      statusCode: 500,
      body: JSON.stringify({ message: 'unknown error!' }),
    });
  }
}

ハンドラのコードは、「Usage · Chart.jsと同じグラフを描画してS3に保存し、そのURLを返す」ことをしています。
これら3つのファイルを同じ階層に配置し、 yarn run build を実行するとdockerのAmazon Linuxの環境内でビルドが走り、成果物が node_modules へ出力されます。
あとはその node_modules とハンドラのコードをzipで圧縮してアップロードすれば完了です。

さて、できたAPIを実際に叩いてみると…

{
  "url": "https://******.s3.ap-northeast-1.amazonaws.com/hoge.png"
}

というレスポンスが返ってきて、さらにこのURLにアクセスすると… image.png (51.2 kB) この画像が得られました!
よさそうです。

外側から設定値を渡せるようにしてみる

動作が確認できたので、続いて外部からChart.jsの設定値を指定できるようにしてみます。

// handler.js

'use strict';

const { CanvasRenderService } = require('chartjs-node-canvas');
const AWS = require('aws-sdk');
const s3 = new AWS.S3();

exports.handler = async function(event, context, callback) {
  try {
    const options = JSON.parse(event.body);
    const queryParams = event.queryStringParameters || {};
    const width = parseInt(queryParams.width, 10) || 800;
    const height = parseInt(queryParams.heght, 10) || 600;
    const renderService = new CanvasRenderService(width, height);
    const buffer = await renderService.renderToBuffer(options)
    const params = {
      Bucket: '******',
      Key: 'hoge.png',
      Body: buffer,
      ContentType: 'image/png',
      ACL: 'public-read',
    };
    const data = await s3.upload(params).promise();
    callback(null, {
      statusCode: 200,
      body: JSON.stringify({ url: data.Location }),
    }); 
  } catch (e) {
    console.log(e);
    callback(null, {
      statusCode: 500,
      body: JSON.stringify({ message: 'unknown error!' }),
    });
  }
}

クエリパラメータで画像のサイズを、リクエストボディのJSONでChart.jsに渡す引数を指定するようにしました。

{
  "type": "bar",
  "data": {
    "labels": [
      "6日(火)",
      ...
      "12日(月)"
    ],
    "datasets": [
      {
        "label": "古文",
        "data": [
          0,
          0,
          142,
          0,
          0,
          150,
          0
        ],
        "lineTension": 0,
        "borderColor": "#e30c0c",
        "borderWidth": 1,
        "fill": "start",
        "backgroundColor": "rgba(255,75,49,1)"
      },
      ...
      {
        "label": "English",
        "data": [
          0,
          0,
          0,
          0,
          0,
          0,
          0
        ],
        "lineTension": 0,
        "borderColor": "#33b377",
        "borderWidth": 1,
        "fill": "start",
        "backgroundColor": "rgba(98,220,156,1)"
      }
    ]
  },
  "options": {
    "scales": {
      "yAxes": [
        {
          "ticks": {
            "beginAtZero": true
          },
          "stacked": true
        }
      ],
      "xAxes": [
        {
          "stacked": true
        }
      ]
    }
  }
}

できたAPIに対して上記のようなJSONでリクエストをして得た画像がこちらです。 image.png (70.2 kB) 日本語が豆腐になっている!!!!

色や値は指定通りですが、テキストに問題がありそうです。
原因はAmazon Linuxに日本語フォントがインストールされていないことなので、さくっとフォントを追加しましょう。
TTF形式の日本語対応フォント(ここではIPAexフォントを例にします)を用意し、同一フォルダ内に置いて以下のコードをハンドラに追加します。1

const path = require("path");

Canvas.registerFont(path.join(__dirname, 'ipaexg.ttf'), { family: 'ipaex' });

これで再度リクエストしてみると、問題なく日本語もレンダリングできるようになりました! image.png (69.6 kB) 完璧ですね。

まとめ

AWS Lambdaは汎用性が高く、想像以上の幅広い要件に対応することができます。
また、node-canvasのようなライブラリを利用することで、画像加工等のリソース配分が難しい処理をオンデマンドな環境で対応することが可能になりました。
うれしいですね。


  1. フォントのライセンスによっては使用できない場合があるので注意してください。

Apollo-iOSを使用してGraphQLを叩く

こんにちは。
iOSエンジニアの弘田です。

みなさんGraphQLはご存知ですか?
知らない方は弊社のエンジニアがGraphQLの記事を書いているのでぜひ読んでみてください。
GraphQLを導入しようとしている話

Studyplusのアプリで一部GraphQLを使用する際にApolloを使用しました。

Apolloって?

ApolloはGraphQLベースのデータスタックです。
環境別にライブラリがあり、今回はApollo-iOSを使用します。
https://github.com/apollographql/apollo-ios

公式サイト : https://www.apollographql.com/docs/ios/installation.html

Apollo-iOSの選定理由

  • Swift4がサポートされている
  • Apollo-cliでModelを生成できる
    • サーバーが更新されたらクエリを書いてスクリプトを実行すればいいので人為的ミスが減る
      • 詳細は後述

GraphQLをPlaygroundから叩いてみる

まずはApollo-iOSを使用せず、普通にGraphQLのエンドポイントを叩いてみましょう。

GraphQLはクライアント側で必要な情報だけを取得するためにクエリを書く必要があります。
クエリはJSON形式で記述します。

このようなデータがあるとします。

user {
    id
    name
    age
}

このときアプリ側で必要な情報はnameだけあれば問題ない時ありますよね。
REST APIの場合はエンドポイントが/userのようになっていて、
idやageも返却されresponse内容に使わない情報が含まれていることがよくあります。

ではGraphQLのnameだけをリクエストするクエリを書いてみましょう。

{ user { name } }

このクエリをURLRequestのbodyに入れてPOSTします。

import UIKit
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

let url = URL(string: "http://localhost:3000/graphiql")

var request = URLRequest(url: url!)
request.httpMethod = "POST"
request.addValue("application/json; charaset=utf-8", forHTTPHeaderField: "Content-Type")

//認証Headerが必要な場合
request.addValue(String(format: "OAuth %@", token), forHTTPHeaderField: "Authorization")

let query = "{ user { name } }"

let body = ["query": query]
request.httpBody = try! JSONSerialization.data(withJSONObject: body, options: [])
request.cachePolicy = .reloadIgnoringLocalCacheData

let task = URLSession.shared.dataTask(with: request, completionHandler: {data, reposnse, error in
    
    if let error = error { print(error); return }
    guard let data = data  else { print("Data is missing"); return }
    do {
        let json = try JSONSerialization.jsonObject(with: data, options: []) as? [AnyHashable: Any]
        guard let data = json?["data"] as? [AnyHashable: [[AnyHashable: Any]]] else { return }
        guard let users = data["user"] else { return }
        users.forEach{user in
        guard let name = user["name"] else { return }
        print(name)
        }
        
    } catch let e {
        print("Parse error: \(e)")
    }
})
task.resume()

これでnameだけの取得ができます。
ただこれだとJSONに変換して文字列でvalueの取得をする必要があり、型のない世界になってしまします。
Codableでやってもいいですが、
queryに変更があった場合都度Modelクラスを手で直す必要がありミスに気づきにくい点があまりよくありません。

Apolloを使用すれば今あげた問題点を解決することができます。

導入

CocoaPods・Carthage両方対応に対応しています。

pod "Apollo
github "apollostack/apollo-ios"

必要なファイルをApollo-cliでダウンロード&生成する

Apollo-cliでclassの生成をするのでインストール
※Apollo-cliにはnodeが必要なので入っていなければ先にnodeをインストール

npm install -g apollo

schema.jsonを配置するディレクトリに移動し、Apollo-cliのコマンドを実行

apollo schema:download --endpoint="エンドポイント"

認証Headerが必要な場合

apollo schema:download --endpoint=エンドポイント --header="Authorization: OAuth <Token>"

schema.jsonはサーバー側で用意します。
schema.jsonは後述のAPI.swiftを生成するのに必要です。


コマンドが成功したら実行したディレクトリにschema.jsonがダウンロードされます。
次にschema.jsonがあるディレクトリでAPI.swiftを生成するコマンドを実行します。

apollo codegen:generate --queries="$(find . -name '*.graphql')" --schema=schema.json API.swift

指定したディレクトリにAPI.swiftを生成したい場合

apollo codegen:generate --queries="$(find . -name '*.graphql')" --schema=schema.json ./hoge/API.swift

上記のコマンドを実行したら指定したディレクトリに空のAPI.swiftが生成されます。
次に中身を生成するために必要な.graphqlファイルを作成します。

.graphqファイルを作る

Apolloを使用してGraphQLを叩く場合Queryは文字列でなく.graphql拡張子のファイルを作成してqueryを記述します。
一つのQueryに対して一つの.graphqlファイルを用意します。
⌘ + NでEmptyを選択し今回はnameを取得するファイルなのでName.graphqlとします。
※.graphqファイルはAPI.swfit生成コマンドを実行するディレクトリ配下に配置します。

query Query名 {
    取得する情報のkey
}

今回は全ユーザーのユーザー名を取得するQueryです。

query getAllUserName {
    user {
        name
    }
}

.graphqlファイルを作成したらもう一度API.swiftの生成コマンドを実行します。
実行すると先ほど作成したName.graphqlを読み込んでAPI.swiftの中身が生成されます。
API.swiftが生成されるときに.graphqlファイルの[クエリ名Query]というclassが生成されます。
例) GetAllUserNameQuery

Apolloを使用してGraphQLを叩く

let configuration: URLSessionConfiguration = .default
configuration.httpAdditionalHeaders = ["Content-Type": "application/json; charaset=utf-8",
                                       "Authorization": String(format: "OAuth %@", <TOKEN>)]
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData

let apiPath: String = String(format: "エンドポイント")
let url = URL(string: apiPath)
let apollo = ApolloClient(networkTransport: HTTPNetworkTransport(url: url!, configuration: configuration,sendOperationIdentifiers: false))

//queryはapollo codegen:generateコマンドで生成されたGetAllUserNameQueryを指定
apollo.fetch(query: GetAllUserNameQuery, resultHandler: {(result, error) in
            
        guard let error = error else {
            print(error)
            return
        }
       
        //Userクラスの配列
        guard let users = result?.data?.name as? [GetAllUserNameQuery.Data.User] else { return }
        print(users[0].name)
    })

まとめ

手順が多く感じられるかと思いますが、schema.jsonとAPI.swiftの更新をスクリプトで行なっています。
なので弊社での更新手順はこのようになります。
1. サーバー側の更新
2. .graphqファイルの作成 & クエリの記述
3. スクリプト実行

Q.XcodeのRun Scriptではなくなぜスクリプトファイルなのか。
A.担当以外のエンジニアがビルドした際に担当外のファイルに変更が入るのを防ぐため

スクリプトはGistで公開しています。
https://gist.github.com/hirota-ryo/ebb429c57db25f99a3bd584621fa81b5

感想

初めはとっつきにくさを感じましたが、実際に手を動かしてみると便利だと感じました。

Apolloを使用することで型のない世界から型のあるGraphQL実現できました。
keyの指定でtypoに気がつかなくハマることのない世界、すばらしいですね。

MySQLの0000-00-00 00:00:00という負債とridgepoleの限界

こんにちは、CTOの島田です。 今回は、StudyplusのDBのmigrationで発生した問題とその解決ステップを説明したいと思います。

前提

まずは前提。

  • Aurora MySQL 5.7
  • Rails 5.1.6 (対応当時。今は5.2.2)

schema.rbでの運用

Studyplus本体のmigrationは、色々な経緯によって2018年5月まで、いわゆるRailsのmigration の作法とは異なる方法で運用されてました。

schema.rb でスキーマの状態を管理してはいたのですが通常とはやや異なる管理がされていました。 以下、改変して一部抜粋。

ActiveRecord::Schema.define do
  execute %q(
    CREATE TABLE `samples` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `content_type` varchar(256) DEFAULT NULL,
      `last_modified_at` datetime DEFAULT NULL,
      `issued_at` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
      PRIMARY KEY (`id`),
      UNIQUE KEY `content_type ` (`content_type `)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  )
...
end

このように execute で直接DDLが記述されていました。 開発環境を構築する場合や自動テストを実行するにはこれで良いですが、カラムやインデックスの追加、削除などのALTER TABLE に対してはこれでは対応できません。
そういった変更を各環境に適用するためにはDDLを直接DBで実行するという属人的なオペレーションが必要になっていました。

ridgepoleの導入

そこでschema.rbでの運用による解決として、ridgepole の導入を決めました。
これで煩雑かつ属人的でリスクの高いスキーマ変更作業から解き放たれるかと思ったのですが、別の問題が勃発しました。

github.com

"0000-00-00 00:00:00" 問題

ridgepoleを導入してSchemafileを生成し、以下のようにdry-runを実行するとなぜか差分が発生してしまいました。

以下、改変して一部抜粋。

$ ridgepole -c ./config/ridgepole.yml -f ./db/Schemafile --apply --dry-run
Apply `./db/Schemafile` (dry-run)
change_column("samples", "issued_at", :datetime, {:unsigned=>false, :comment=>nil})
...

# ALTER TABLE `samples ` CHANGE `issued_at` `issued_at` datetime NOT NULL
...

このようなカラムが、なんと60テーブルに及び90カラム以上!

問題の原因は?

差分として change_column... となってしまうカラムには共通点がありました。
それは、全て DATETIME でDEFAULT '0000-00-00 00:00:00'でした。

よくよくSchemafileを確認してみると、確かに

create_table "samples", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
...
  t.datetime "issued_at", null: false
...
end

となっていました。

試しに、

t.datetime "issued_at", null: false

t.datetime "issued_at", null: false, default: '0000-00-00 00:00:00'

とすると、その部分のchange_column... は出力されなくなりました。

ここからは推測ですが、ridgepoleではActiveRecordを利用しているため、Schemafileをexportする際に '0000-00-00 00:00:00'を認識することが出来ないのではないかと考えました。

例えば以下のようなデータがあった場合に、

mysql> SELECT  `samples`.* FROM `samples` ORDER BY `samples`.`id` ASC LIMIT 1 \G
*************************** 1. row ***************************
                 id: 1
...
          issued_at: 0000-00-00 00:00:00
1 row in set (0.00 sec)

ActiveRecord では、

[1] pry(main)> sample = Sample.first
...
=> #<Sample:0x000055b595171600
 id: 1,
...
 issued_at: nil>

というように、nilと認識をされてしまう事と関連があるのではないかと思いました。

これはridgepoleやActiveRecordの不具合でなく、0000-00-00 00:00:00を利用している事が問題であり、0000-00-00 00:00:00の撲滅をしてく事を決めました。

0000-00-00 00:00:00がなぜ良くないかについては、そーだいさんの MySQLの0000-00-00 00:00:00は使ってはならない の記事を参照してもらえればと思います。

soudai.hatenablog.com

暫定対応

ただ撲滅をするといっても数も多く、すぐに全てを対応するのは難しい状態でした。
しかし、スキーマ変更でDDLの直接実行をするのを防ぐ事を優先したかったので、やむえず暫定オペレーションを考えることにしました。

それは、$ ridgepole --export -o Schemafile で生成したSchemafileに default: '0000-00-00 00:00:00' を追加するRakeタスクを作るという苦肉の策でした。

Rakeタスクの内容としては、

  1. ridgepole で Schemafile を export(ファイル名: SchemafileImport)
  2. Rakeタスクにてdefault: '0000-00-00 00:00:00'を追加する対象の列に、defaultを追加して、Schemafileを別途出力(ファイル名:SchemafileExport) という処理になります。

そして、スキーマ変更をする順番は、

  1. $ rake database_export:with_default_datetime
  2. ridgepole の実行にはSchemafileExportを利用する
    • $ ridgepole -c ./config/ridgepole.yml -f ./db/SchemafileExport --apply

という手順となっていました。

以下、Rakeタスクを一部改変して掲載

namespace :database_export do
  desc "Execute ridgepole export"
  task :with_default_datetime do
    # ridgepoleで生成するSchemafile
    IMPORT_OUTPUT_PATH = "./db/SchemafileImport".freeze

    # スキーマ変更時に利用するSchemafile
    EXPORT_OUTPUT_PATH = "./db/SchemafileExport".freeze


    OVERWRITE_TARGETS = {
      samples: ["issued_at", "xxxx_at"],
      xxxs: ["xxxx_at"],
       ...
    }.freeze

    @updated_schema = ""

    puts "exporting schema"
    sh "ridgepole -c ./config/ridgepole.yml --export -o #{EXPORT_OUTPUT_PATH}"
    sh "ridgepole -c ./config/ridgepole.yml --export -o #{IMPORT_OUTPUT_PATH}"

    puts "adding default value to EXPORT file"
    File.open(EXPORT_OUTPUT_PATH, "r") do |file|
      file.each_line("") do |lines|
        line_array = lines.split("\n")
        create_table_line = ""
        create_table_line = line_array[2] if line_array[0]&.match(/^\#/) && line_array[2]&.match(/^create_table/) # 1個目のテーブル用
        create_table_line = line_array[0] if line_array[0]&.match(/^create_table/)
        if create_table_line
          create_table_line_array = create_table_line.split(/\s/)
          table_name = create_table_line_array[1]&.match(/[a-zA-Z\_]+/).to_s
          if OVERWRITE_TARGETS.keys.include?(table_name.to_sym)
            line_array.each do |line|
              col_array = line.split(/\s/)
              col = col_array[3]&.match(/[a-zA-Z\_]+/).to_s
              if OVERWRITE_TARGETS[table_name.to_sym].include?(col)
                lines.gsub!(/#{line}/, line + ", default: '0000-00-00 00:00:00'")
              end
            end
            @updated_schema += lines
          else
            @updated_schema += lines
          end
        end
      end
    end
    puts "updating file"
    File.open(EXPORT_OUTPUT_PATH, "w") do |file|
      file.puts(@updated_schema)
    end
    puts "update finished"
  end
end

問題の解消

しばらくは、このRakeタスクでSchemafileを生成する運用と並行して、0000-00-00 00:00:00を廃止するという地道な作業をしていきました。 ただ、幸運にもほとんとの対象が、created_atupdated_atだったので(なぜ0000-00-00 00:00:00だったのか謎だが)

  t.datetime "created_at", null: false, default: '0000-00-00 00:00:00'
  t.datetime "updated_at", null: false, default: '0000-00-00 00:00:00'

  t.datetime "created_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
  t.datetime "updated_at", default: -> { "CURRENT_TIMESTAMP" }, null: false

のように CURRENT_TIMESTAMPにしていくことで対応できました。

解消手順としては、

  1. 対象カラムを利用している箇所をCURRENT_TIMESTAMPにして問題ないか確認、必要に応じて修正
  2. 修正したコードをデプロイ
  3. Rakeタスクを変更
  4. ridgepoleにてALTER TABLEDEFAULTを修正

という事をひたすら繰り返しました。

あるべき姿

そうして、少しづつ進めてどうにか以下のようにする事ができました。

$ ridgepole -c ./config/ridgepole.yml -f ./db/Schemafile --apply --dry-run
Apply `./db/Schemafile` (dry-run)
No change

最後の対応が終わった時のSlackの投稿

まとめ

Studyplusはリリースから、2019年で7年目を迎えます。
最初はRailsのバージョンが3.0.9でしたが、現在は5.2.2となっており、これまで様々なコントリビューターの方が関わってきました。
そのため、サービスの運用を続けていれば必然的に発生する技術的な課題が弊社にも少なからずあります。
しかし、それらを放置する事なく地道に改善を続けて、モダンな仕組みを取り入れています。
もし、そんなスタディプラスのサーバーサイドの開発に興味がある方は、是非 こちら から応募いただければと思います。
(または、私のTwitter にDMをいただくでも構いません)

info.studyplus.co.jp

新規プロジェクトにVueとVuexを採用してみた

こんにちは。Studyplus開発部の田口です。
新規で立ち上がった開発プロジェクトにVueVuexを採用してみたので、今回はその所感を書こうと思います。

Vue/Vuexの採用理由

今回のプロジェクトで開発するのは、Studyplusのアプリ内ブラウザのWebViewページです。
プロジェクトを開始するにあたって、アプリケーション自体は小規模で、画面の遷移がありつつサーバーとの通信を極力避けユーザーの入力情報を保持したいため、SPAで開発することにしました。
開発にあたって、

  • モダンな技術を採用し、社内に知見を貯めたい
  • フロントエンドの専任がチームにいないため、専任でないエンジニアがコードを読むときなるべくコストがかからない
  • (上記に関連して)日本語のドキュメントがあるとよい
  • メインで開発を行う自分が過去に少しだけFluxアーキテクチャを触ったことがある

という理由でVueとVuexの採用を決めました。
以下は日本語の公式サイトです

Vue: https://jp.vuejs.org/index.html
Vuex: https://vuex.vuejs.org/ja/

プロジェクトの立ち上げ

Vueの魅力のひとつに、アプリケーションの開発に役立つビルドやルーティングなどの機能が公式のサポートライブラリとして配布されており、エコシステムが充実していることが挙げられます。
今回利用した状態管理のVuexも、ルーティングで利用したvue-routerも公式が出しているライブラリです。基本的に公式が出しているライブラリが充実しているため、ライブラリの選定に悩まないところは採用して良かったと思った点です。
そして、プロジェクトの雛形を作成するCLIツールでああるVue CLIも、公式が出しているライブラリです。

Vue CLIを利用する上での注意点

Vue CLIはv3がリリースされて名称が変わっています。npm installする際に注意が必要です。

$npm install -g @vue/cli
または
$yarn global add @vue/cli

でインストールします。
パッケージ名の前半の@vueは、npmでライブラリを公開する際の名前空間です。この名前空間を利用することで、vue公式が出しているパッケージであることがすぐに分かったり、ライブラリ名が他のライブラリと被ってしまうことがなくなります。

@vue/cliをインストールする際に、Vue CLI v2以前のvue-cliがすでにインストールされている場合は、古いバージョンのアンインストールが必要です。また、Vue CLI v3のインストールにはNode.jsのv8.9以上のバージョンが必要です。

@vue/cliでプロジェクトの雛形を作成する

プロジェクトの作成はvue create project-nameでできます。すでにプロジェクトディレクトリを作成していた場合は、そのプロジェクトでvue create .を実行します。
コマンドライン上で自分が利用するパッケージを選択していくだけで、そのパッケージが入ったプロジェクトの雛形が作成されます。非常に便利です。

Webpackの設定を追加できる

Vue CLI v3で作成してプロジェクトは、v2とは違ってWebpackの設定が隠蔽されています。
ですが、自分でWebpackの設定を追加したい場合は、vue.config.jsというファイルをルートディレクトリに作成し、そこに追加の設定を書くことでWebpackの設定を独自にカスタマイズできます。 今回のプロジェクトでは独自設定はほとんどしませんでした。詳しくはリファレンスを参照してみてください。

Vuexを利用した状態管理

Vuexを使用した理由

今回はアプリケーションの状態管理をVuexで行うことにしました。
上述の通り、フロントエンド専任のエンジニアがいなかったことと、メインの開発を担当する自分がFluxアーキテクチャを触った経験があったということもありVuexを採用しました。
Nuxtを利用することも考えましたが、小規模なプロジェクトなこともあり今回はVuexのみ扱うことにしました。

Vuexの基本的な考え方

スクリーンショット 2019-01-24 22.34.12.png (30.2 kB)

Flux、 Redux そして The Elm Architectureから影響を受けています。

(https://vuex.vuejs.org/ja/ より引用)

とあるように、Vuexは単一方向のデータの流れが特徴のライブラリです。
基本的な考え方はFluxアーキテクチャと同じです。

Vuexでは、stateの更新をactionではなくmutationで行います。stateの更新の唯一の方法はmutationをcommitすることで、actionがdispatchするのはmutationということになります。ここがRedux等と違うところでしょうか。

actionの使い所は、非同期処理を行いその結果をstoreに反映させたいといった場面です。
React/Reduxを使う場合は、redux-sagaやredux-thunkなどのmiddlewareを導入すること考慮に入れる必要がありますが、Vuexではコアな機能としてデフォルトで利用できるので、ライブラリの選定コストがかからないところがいいですね。

今回は初めてのVuexの利用ということで、基本的に全てのstateの変更はactionを通すようにし、mutationの命名を定数で管理するようにしました。以下のような感じです。

// src/mutation_type.js

export const hogeTypes = {
  // hogeのリクエストが成功する
REQUEST_SUCCESS: "REQUEST_SUCCESS",

// hogeのリクエストが失敗する
REQUEST_FAILURE: "REQUEST_FAILURE"
}
...
// src/store/modules/hoge.js

import { hogeTypes } from "../../mutation_type"

const hogeModule = {
  namespaced: true,
  ...
  mutations: {
    [hogeTypes.REQUEST_SUCCESS](state, payload) {
      // 成功時のstateへの処理
    },
    [hogeTypes.REQUEST_FAILURE](state, payload) {
      // 失敗時のstateへの処理
    }
  },
  actions: {
    async requestHoge({ dispatch }) {
      // リクエスト処理
      try {
        const hoge = await fetchHoge()
        dispatch("doneFetchHoge", hoge)
      } catch (e) {
        dispatch("failureFetchHoge", e)
      }
    },
    doneFetchHoge({ commit }, hoge) {
      // リクエスト成功
      commit("REQUEST_SUCCESS", hoge)
    },
    failureFetchHoge({ commit }, e) {
      commit("REQUEST_FAILRE", e)
    }
  }
}

必ずしも全てのmutationをactionを通じてcommitする必要があるわけではなく、プロジェクトの規模やスピード感に応じて柔軟に変更できるので、今後Vuexを採用する場合はもっと柔軟に利用していきたいですね。

Vue/Vuexを利用してみて

SFCが分かりやすい

今回のプロジェクトはVue/VuexをSFC(Single File Component)で実装していったのですが、非常に書きやすかったです。
Vuexではviews(pages)で一つの画面の構成を表現し、そのviews内でいろいろなcomponentsを呼び出します。
views(pages)とcomponentsの関係やcomponentsの切り分け方など、下記のブログが非常に参考になりました。

aloerina01.github.io

SFCでコンポーネントを書くと、template script styleをすべて一つのファイル内で定義できるので、見通しがいいと感じました。styleはscopedにもできるので、セレクタの命名で機能ごとのプレフィックス等を考える必要もなく、シンプルな命名ができます。
個人的にはSFCでのコンポーネント実装が書きやすくて便利だと感じました。

storeのmodulesが名前空間で分かれている

storeのmodules化によってstoreを機能ごとに分割でき、さらにそのmodulesに名前空間を設定できるのも便利だと思いました。mutationの命名で被ってしまうことがあっても、namespacedなstoreなら問題ないです。

ハマったところ

名前空間で分けたstoreの引数付きgettersの呼び出し

storeにgettersを定義する際、引数付きのgetterを定義できます。
その引数付きのgetterをmapGettersで呼び出そうとした際にどうしても呼び出せなかったところで時間を使ってしまいました。
mapGettersではなく、普通に(this.$store.getters.getData(args)のように)呼び出すことで解決できます。
ただし、namespacedなstoreに定義したgettersの呼び出しは、this.$store.getters["namespace/getData"](args)といった書き方をします。
gettersのプロパティにアクセスする際に.ではなく[ ]を利用します。これはES2015の算出プロパティという構文で、[ ]で括られた部分が評価され、その値がプロパティ名になります。(この算出プロパティ、上述の例のmutationsにも利用されています。)
namespacedなstoreに定義されたgetterは"namespace/getData"というプロパティ名なので、[ ]を使わないとアクセスできないということです。
今回のプロジェクトで初めて知った構文だったのでちょっと戸惑いました。

こうしたほうがよかったと思うところ

TypeScriptの導入

今回のプロジェクトでは、ほぼ初学でVueを使い、プロダクトも小さいためJavaScript(ES2015)で実装しました。
しかし、主にstore(state)の設計をする際に型がほしいと強く思いました。
stateは単なるJavaScriptのObjectです。プロパティ名を決めても、入ってくる値を型で制限できないので、意図した型ではない値が入ってしまうことが考えられます。
単にSPA内でstateに値を追加するときもバグに気づきやすくなるので有用ですが、外部のサーバーと通信してJSONで取得したレスポンスをstateに追加するといったときなどに特に効果があると思います。
今回導入しなかったことの後悔を忘れず、次の機会があったら積極的にTypeScriptを導入しようと思います。

まとめ

Vue/Vuexを初めて利用しましたが、わかりやすい書き方で日本語のドキュメントがあるところが導入障壁を下げてくれているなと感じました。
とっつきやすさというのも重要な要素ですし、エコシステムが充実している、活発なコミュニティ活動がある、など盛り上がっている理由がよくわかります。
今後もSPAの開発プロジェクトで積極的に導入していきたいと思います。

インフラエンジニアがオフィス移転で考えたこと[後編]

インフラエンジニアの菅原です。

弊社のオフィス移転が完了しました。
f:id:ksugahara08:20190111111915j:plain

前編で引越しの計画を立てたものの、事は上手く運ばず、色々学びがありました。

今回はインフラエンジニアがオフィス移転で考えたこと[前編]の続きとして振り返りと学びを共有したいと思います。

プロバイダー契約

振り返り

現状の棚卸しをしたところインターネット回線の2重契約や電話回線の契約に不要なFAXの契約が入っていたりしていたため、契約プランを変更することでコストカットを進めました。

また、前編でも話した通り社内ネットワークの平行期間を設けました。具体的には以下の様にインターネット回線契約を「解約」と「新規」にしました。
「解約」・・・旧オフィスを12月末まで
「新規」・・・新オフィスを12月頭から
平行期間を設けることで余裕を持って動作確認を行うことが出来ました。
しかし、全て上手くいったわけではありませんでした。プロバイダーとの認識のズレで旧オフィスのインターネット回線を「移行」にさせられてしまい、12月頭にインターネットが使えないという事態も起きました。

学び

棚卸しはコストカットに繋がることもありますが、契約の変更はよく検討した方が良いと思います。電話回線契約が必要だったりと、要件を見極めるためにも前任者に連絡が取れるのなら事情を確認してからにした方が良いと思います。

社内ネットワークの移行は平行期間を設けたことで余裕が生まれました。
しかし、インターネットが旧オフィスで使えない期間ができてしまいました。プロバイダーには旧オフィスの解約日を書面等で明確に伝えておくべきだと思います。

AWSの設定変更

振り返り

弊社では社内の固定グローバルIPでアクセス制御をしている部分があります。
移転時には固定IPが変更になってしまうため、各サーバーに設定変更をかける必要がありました。

具体的にはSecurity Group、IAM、nginxの設定箇所を移転に合わせて変更しました。
事前に移転先の固定IPがプロバイダーから発行されるので、引越し前に新固定IPを追加し、引越し後に旧固定IPを削除する形を取りました。

学び

前任者の方が移転のことも鑑みてAnsibleで設定をまとめてあったので非常に楽でした。
構成管理ツールでサーバーの設定を記述しておくことはオフィス引越しでもメリットがあります。

コミュニケーション

振り返り

今回の移転で最もコストが掛かったのは、関係各所とのコミュニケーションでした。話したことと実際のアウトプットが違っていたり、合意した内容に齟齬が発生していました。
ドキュメントを作って満足するのではなく、相手が理解・納得して合意を取る。進捗確認・共有という基本的な部分が当初から出来ていなかったのは反省点だと思います。

学び

闇雲にミーティングを行うのは愚策だと思いますが、話した事や決まった事はメールやドキュメントで書類にして合意を形成するという基本をちゃんと行っておくべきだと思います。基本大事!!

スケジュール

振り返り

実際に作業して頂く先方とスケジュールを確認したところチグハグになっていることがわかりました。
コミュニケーション不良もありましたが、スケジュールが明確になっていない、進捗がわからないという状況になりかけていました。

学び

自分兼共有用のスケジュールやチェックリストを作成して、共有するようにしました。
具体的には以下のようなものを用意しました。

日付 担当 内容 場所 ステータス
11/15 電話回線業者 電話回線工事(立会) 御茶ノ水
11/21 ISP業者 光回線工事(ダークファイバー側)(立会) 御茶ノ水
11/27 ISP業者 光回線工事(1階から4階側)(立会) 御茶ノ水
12/3 社内インフラ 新固定IPをAnsibleで追加する 代々木
12/4 電話回線業者 電話回線本工事(立会) 御茶ノ水
12/4,5 電気工事業者 無線AP配線取付、新規ルーター設置 ネットワーク試験 電話先行配線(立会) 御茶ノ水
12/14 社内インフラ 床下配線回収、複合機の持ち出し 代々木
12/15 電気工事業者 2台目ルーター設定、動作確認、電話設備撤去運搬取付、複合機用LAN増設 御茶ノ水
12/15 複合機業者 複合機の移設、設定 御茶ノ水
12/15 電話回線業者 旧モデム一式返却(13時〜17時立会必要) 代々木
12/15 社内インフラ 床下配線回収 代々木
12/17 複合機業者 2台目複合機の増設、設定 御茶ノ水
12/17 社内インフラ 旧固定IPをAnsibleで削除する 御茶ノ水
12/19 ISP業者 旧モデム一式返却(15時頃) 代々木
12/20 複合機業者 2台目複合機の増設、設定 御茶ノ水

全体から逆算して、いつまでに何が出来ていないといけないか把握することは大切だと改めて思いました。

移転作業

振り返り

旧オフィスでは床下の配線を弊社側で行っていたため原状回復が必要でした。ビル側の要望としても元に戻す様に言われていたため配線を回収する必要がありました。

f:id:ksugahara08:20190111114139j:plain
床下の配線回収

学び

原状回復でどこまで行うかはビルの管理を行っている方に確認したが良いと思います。有志を募って回収作業を行いましたが、回収方法などの計画は事前に練っておいた方が良いと思います。

まとめ

  • プロバイダー契約は棚卸しして、ゼロベースで検討してみても良いと思います。
  • 移転時はインターネット回線の平行期間を設けると色々とメリットがありました。
  • コミュニケーション不足陥らないためにスケジュール確認や進捗確認などまめに行うべきだと思います。エビデンスは文面で残す様にしましょう。
  • 移転作業における自分の作業範囲を確認しましょう。
御茶ノ水新オフィス紹介

新オフィスにはイベントスペースやバーカウンター、自由に使える作業スペースもあります。

f:id:ksugahara08:20190111112818j:plain
イベントスペース
f:id:ksugahara08:20190111112659j:plain
バーカウンター
f:id:ksugahara08:20190108105522j:plain
作業スペース

弊社では新しいオフィスで働く仲間を募集しております。
興味のある方はこちらからご連絡下さい。

Railsで流量の多いデータをページネーションする

はじめまして、for School事業部のサーバーサイドエンジニアの冨山です。
今回はfor SchoolのリニューアルにおいてAPIでリアルタイムなデータをページネーションする上でJinraiというカーソルベースのページネーションライブラリを開発しました。
今回はその開発の経緯や直面した課題についてお話していきたいと思います。

TL:DR

  • ページベースのページネーションだとクライアントへ渡すデータに重複が発生する。
  • 基点となるレコードを示すカーソルをクライアントへ渡すことでOFFSETを意識することなく特定の範囲のデータを取得できる。
  • IDをクライアントに渡したくない場合、カーソルとして渡す値がユニークで無いとクライアントに渡らない情報が発生する。
  • ソートキーの値が重複する場合IDでもソートすることで上の問題が解決する

Jinraiの使い方

class Post < ApplicationRecord
  cursor_per 5 # 一度に取得する件数の指定
end

class PostsController < ApplicationController
  def index
    @posts = Post.cursor(since: params[:since], till: params[:till])
  end
end

posts = Post.cursor # 最新5件のレコード
since = posts.till_cursor # postsの最後のレコードを示すカーソル
Post.cursor(since: since) # postsの最後のレコード以前の5件のレコード

till = posts.since_cursor # postsの最初のレコードを示すカーソル
Post.cursor(till: till) # postsのレコードより最新のレコード

デフォルトではidでソートされますが、id以外のキーでソートしたい場合sort_atオプションを渡すことで実現できます。

Post.cursor(sort_at: :updated_at) #=> updated_atでソートされた上から5件のレコード

開発経緯

Studyplus for Schoolでは生徒の勉強記録の情報をタイムラインに表示するような機能があります。

当初はKaminariを使っていましたがそれだとデータの流量が増えたときにページ間でデータの重複が発生するためカーソルベースのページネーションを作ろうという話になりました。

課題1

最新データから一定件数ずつ取得するタイムラインでは常にデータの先頭に最新のレコードが挿入され続けるためページ型のページネーションだとデータの重複が発生します。
以下にその一例を示しますが、下のような投稿のテーブルがあるとします。

posts

id text updated_at
10 post10 2018/12/13 14:12
9 post09 2018/12/13 14:11
8 post08 2018/12/13 14:10
7 post07 2018/12/13 14:09
6 post06 2018/12/13 14:08
5 post05 2018/12/13 14:08
4 post04 2018/12/13 14:06
3 post03 2018/12/13 14:05
2 post02 2018/12/13 14:04
1 post01 2018/12/13 14:03

Kaminariで上のテーブルから最新のデータを5件づつデータを取得すると、

Post.order(id: :desc).page(1).per(5).pluck(:id) #=> [10, 9, 8, 7, 6]

発行されているSQLは

SELECT posts.* FROM posts ORDER BY posts.id DESC LIMIT 5 OFFSET 0

となります。

ここで新しい投稿があり、データ更新されるとテーブル内の情報は下のように更新されます。

posts

id text created_at
11 post11 2018/12/13 14:20
10 post10 2018/12/13 14:12
9 post09 2018/12/13 14:11
8 post08 2018/12/13 14:10
7 post07 2018/12/13 14:09
6 post06 2018/12/13 14:08
5 post05 2018/12/13 14:08
4 post04 2018/12/13 14:06
3 post03 2018/12/13 14:05
2 post02 2018/12/13 14:04
1 post01 2018/12/13 14:03

このタイミングで先程の方法で2ページ目を取得すると、発行されるSQLは

SELECT posts.* FROM posts ORDER BY posts.id DESC LIMIT 5 OFFSET 5

となるため

Post.order(id: :desc)page(2).per(5).pluck(:id) #=> [6, 5, 4, 3, 2]

は、id=6のデータが重複してしまいます。 ページベースのページネーションで上のようなリアルタイムデータを扱うと重複や削除処理などが入れば表示されないデータが発生します。

解決策

そこで最初と最後のレコードを一意に特定可能な値を渡すことで、そのレコードを基点に取得すれば上のような問題は発生しなくなります。 一般的な方法にIDを渡す方法があり、IDはユニークなのでクライアントへ直前に渡したデータの任意のIDをリクエストに含めて貰えれば重複なく前後のデータを返すことができます。

例えばkaminariでは2ページ目を取得する場合

curl -X GET http://example.com/users?page=2

といったリクエストを受けますが、Jinraiでは以下のような形でリクエストを受けます。

curl -X GET http://example.com/users?since=6

ここで発行されるSQLは以下のようになります。

SELECT  posts.* FROM posts WHERE posts.id < 6 ORDER BY posts.id DESC LIMIT 5

この方法であれば上の新しい投稿がされた場合であっても直前に受け取ったデータの最後のIDをリクエストのsinceパラメータに含めてあげることで、重複なくデータを取り出すことができます。

課題2

上でデータの重複は解決できますが、クライアントにIDを渡したくかったり、IDでなくupdated_atなどの異なる属性でソートされたデータが欲しいといったケースが出てくると思います。

下はpostsをupdated_at > idの順序でソートされたデータです。

posts

id text updated_at
10 post10 2018/12/13 14:12
9 post09 2018/12/13 14:11
8 post08 2018/12/13 14:10
7 post07 2018/12/13 14:09
5 post06 2018/12/13 14:08
6 post05 2018/12/13 14:08
4 post04 2018/12/13 14:06
3 post03 2018/12/13 14:05
2 post02 2018/12/13 14:04
1 post01 2018/12/13 14:03

こちらのテーブルから最初の5件を取得すると

Post.cursor(sort_at: :updated_at).pluck(:id) #=> [10, 9, 8, 7, 5]

となります。次にその次の5件を上と同じSQL取得すると、

SELECT posts.* FROM posts
WHERE posts.updated_at < '2018/12/13 14:08' ORDER BY posts.updated_at DESC LIMIT 5

という形ですが、直前に取得したデータの最後(id=5)のupdated_atが次のレコード(id=6)のupdated_atと同じ値の為id=6のレコードが取得できなくなってしまいます。

解決策

そこでJinraiではid以外の属性でソートするときは以下のようなSQLを発行します。

SELECT posts.* FROM posts
WHERE (posts.updated_at < '2018/12/13 14:08')
OR ((posts.updated_at = '2018/12/13 14:08') AND (posts.id > 6))
ORDER BY posts.updated_at DESC LIMIT 5

WHERE句でソートキーの値が重複する可能性を考慮して、同じだった場合より大きいidのデータを取得するようにしています。これを実装上でArelを使用してコードで以下のように表現しています。

if sort_at != primary_key
  condition_1 = arel_table[sort_at].send(rank, attributes[sort_at])
  condition_2 = arel_table.grouping(arel_table[sort_at].eq(attributes[sort_at])
    .and(arel_table[primary_key].send(rank_for_primary, id)))
  relation = where(condition_1.or(condition_2))
else
  relation = where(arel_table[primary_key].send(rank, id))
end

まとめ

以上のようにjinraiはページベースのものに比べて、全ページ数などが取得できないといったデメリットはありますが今回のようなケースでは過不足なくデータの受け渡しを行えます。
流量が多く最新からデータを取得したいようなケースに扱いやすい形でデータを提供できるような形で開発されています。

ソースは以下のGithubにあり弊社メンバー以外の方の開発参加も歓迎しています。
OSSの開発に関することはGitterにてやり取りされている為興味がある方は是非一度ルームで声をかけてください。
Github(studyplus/jinrai)
Gitter