AndroidのCanvasを使ってバーコード読取のファインダーを作り直した話

リサーチ・アンド・イノベーションの高田(tfandkusu)です。こだわりを持ってAndroidアプリ作りができる環境を求めて、今年1月に入社し、CODEAndroidアプリ開発を担当しています。このたびサードパーティーから買っているバーコードデコードライブラリを別のものに差し替えることとなりましたが、そのライブラリには特徴的な仕様があり、それに合わせてバーコードスキャン範囲を示す表示(以下、ファインダー)にも改修を加えることにしました。この記事ではその部分をCanvasを使って再実装したことを紹介します。

バーコード読取画面
バーコード読取画面

ファインダーをよく見る

ファインダーは下の図で示すとおりです。

  • 外側は白い半透明
  • 中心に穴が開いている
  • 4角に不透明の線がある
  • 線には外側だけ陰がある

全体
全体

外側だけに影がある
外側だけに影がある

バーコードデコードライブラリの仕様

バーコードデコードライブラリの仕様ですが、サイズが640x480または480x640の画像のみ対応でした。大きさについては縮小すれば良いですが、縦横比は変えられないのでデコード範囲は縦横比4:3の範囲であると明示する必要があります。よって、カメラプレビューをこのようなルールで切り取ることにしました。

// width、heightはそれぞれカメラプレビューサイズ。
// AndroidのCamera APIの仕様で画面は縦向きでもカメラプレビューは横に倒れている。
val baseHeight = height * (1080 - 96 * 2) / 1080
val baseWidth = baseHeight * 3 / 4
val left = (width - baseWidth) / 2
val top = (height - baseHeight) / 2
val right = (width + baseWidth) / 2
val bottom = (height + baseHeight) / 2

切り取りルール
切り取りルール

カメラプレビューから取り込まれた画像データはleft,top,right,bottomの位置で切り取られ、480x640に縮小します。余談ですが、このあたりの画像処理はOpenCVライブラリで行っています。過去にはqiitaにOpenCVのサイズを減らす記事を投稿したことがあります。

改修前の実装

改修前の実装はこのようなPNGファイルをImageViewで貼り付けていました。

PNGファイル
PNGファイル

今回は画面横幅の (1080 - 96 * 2) / 1080、縦幅はその3/4の領域に穴を開けたいので、機種ごとに画面の縦横比が違うと考えると画像は使えません。

CustomViewを使う

画像が使えなければCustom View Componentsを使いCanvasで描画します。

Viewクラスを継承する

CustomViewはViewクラスを継承して作ります。

class CODEViewfinderView : View {
    /**
     * ソースコードから追加する用コンストラクタ
     */
    constructor(context: Context) : super(context)

    /**
     * XMLから追加する用コンストラクタ
     */
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
}

コンストラクタは4種類ありますが、1引数と2引数のものだけを実装すれば良さそうです。 (参考文献)

Viewの大きさとdpを取得する

Viewの大きさはonSizeChangedメソッドが大きさが変わるたびに呼ばれるので、それをオーバーライドして取得します。CODEのバーコード読取画面では大きさが変わらないので最初の1回だけ実行されます。 dpはresourcesプロパティからdisplayMetricsプロパティをたどって取得できます。

override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) {
    // widthが幅、heightが高さ
    // 1dpのピクセル数
    this.dp = resources.displayMetrics.density
    // 続きます。
}

描画するビットマップを作成する

描画するビットマップを作成します。大きさはViewのサイズと同じです。Canvasクラスのコンストラクタに渡すことで、そのインスタンスから自由自在に図形などを描いて画像を作れます。

private lateinit var bitmap : Bitmap

override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) {
    // 略
    // ビットマップを作成
    this.bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    // Canvasを作成
    val canvas = Canvas(bitmap)
    // 続きます。
}

ビットマップに対して描画する

Porter-Duffモードを使いこなす

ファインダーをビットマップに描画します。まず全体を半透明の白で塗りつぶした後に透明を描画して穴を開けています。ここで重要な行はdrawHoleメソッドの paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC) です。デフォルト設定は PorterDuff.Mode.SRC_OVER になっています。このモードは描画元色のアルファチャンネル(不透明度)を反映した上で描画されます。アルファチャンネル0で黒の矩形を描画しようとしたところ、透明な黒を描画して結果としてなにも描かれません。一方、PorterDuff.Mode.SRC を設定すると、アルファチャンネルを含めてすべての要素を描画元に置き換えます。よって矩形を描いた箇所は穴になります。

ファインダーの穴
ファインダーの穴

/**
 * 描画情報。onDrawメソッドでも使うので、ここで作る
 */
private val paint = Paint()

override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) {
    // 略
    // 全体を半透明の白で塗りつぶす
    drawWhole(canvas)
    // 穴の位置を計算「バーコードデコードライブラリの仕様」節参照
    val baseWidth = width * (1080 - 96 * 2) / 1080
    val baseHeight = baseWidth * 3 / 4
    val left = (width - baseWidth) / 2
    val top = (height - baseHeight) / 2
    val right = (width + baseWidth) / 2
    val bottom = (height + baseHeight) / 2
    // 全体を描画
    drawWhole(canvas)
    // あとでここに増えます。
    // 穴を描画
    drawHole(canvas, left, top, right, bottom)
    // 続きます。
}
/**
 * まず、全体を薄く白く描画
 */
private fun drawWhole(canvas: Canvas) {
    paint.reset()
    paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC)
    paint.color = Color.argb(0xff * 6 / 10, 0xff, 0xff, 0xff)
    paint.maskFilter = null
    // 全体を塗る
    val rect = Rect()
    rect.left = 0
    rect.top = 0
    rect.right = bitmap.width
    rect.bottom = bitmap.height
    canvas.drawRect(rect, paint)
}

/**
 * 穴を開ける
 */
private fun drawHole(canvas: Canvas, left: Int, top: Int, right: Int, bottom: Int) {
    paint.reset()
    // アルファチャンネルをそのまま書く
    paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC)
    paint.color = 0x00000000
    paint.maskFilter = null
    val rect = Rect()
    rect.left = left
    rect.top = top
    rect.right = right
    rect.bottom = bottom
    canvas.drawRect(rect, paint)
}

BlurMaskFilterで陰を描画する

陰がついている4角の線を描画します。

陰がついている4角の線
陰がついている4角の線

まず陰の元となる形状を作成します。

    /**
     * 穴が空いているところの4角に陰を描く
     * @param canvas 描画ハンドル
     * @param left 穴の左
     * @param top 穴の上
     * @param right 穴の右
     * @param bottom 穴の下
     */
    private fun drawEdgeShadows(canvas: Canvas, left: Int, top: Int, right: Int, bottom: Int) {
        // 4角の線の長さ
        val lineLength = (dp * LINE_LENGTH).toInt()
        // 左上の矩形を描画する
        val rect = Rect()
        rect.left = left
        rect.top = top
        rect.right = left + lineLength
        rect.bottom = top + lineLength
        paint.reset()
        paint.color = 0xff000000.toInt()
        canvas.drawRect(rect, paint)  // 後で書き換えます
        //右上、左下、右下も同様に描画する
        rect.left = right - lineLength
        rect.top = top
        rect.right = right
        rect.bottom = top + lineLength
        canvas.drawRect(rect, paint)  // 後で書き換えます
        rect.left = left
        rect.top = bottom - lineLength
        rect.right = left + lineLength
        rect.bottom = bottom
        canvas.drawRect(rect, paint)  // 後で書き換えます
        rect.left = right - lineLength
        rect.top = bottom - lineLength
        rect.right = right
        rect.bottom = bottom
        canvas.drawRect(rect, paint) // 後で書き換えます
    }

陰の元
陰の元

全然違う形ですが大丈夫です。前述の canvas.drawRect(rect, paint) について、描画にBlurMaskFilterを使い外側にぼかせるように書き換えます。

paint.color = 0x44000000
paint.maskFilter = BlurMaskFilter(4 * dp, BlurMaskFilter.Blur.OUTER)
canvas.drawRect(rect, paint)

外側にぼかす
外側にぼかす

これを4角分行った後に、説明が前後していますが、前節のPorter-Duffモードを使った穴の描画を行います。

穴の描画
穴の描画

最後に線を4角の線を合計8本矩形として描画します。

/**
 * 穴が空いているところの4角に線を書く
 * @param canvas 描画先Canvas
 * @param left 穴の左
 * @param top 穴の上
 * @param right 穴の右
 * @param bottom 穴の下
 */
private fun drawEdgeLines(canvas: Canvas, left: Int, top: Int, right: Int, bottom: Int) {
    val lineLength = (dp * LINE_LENGTH).toInt()
    val lineWidth = (dp * LINE_WIDTH).toInt()
    // 左上
    drawEdgeLine(canvas, left, top,
        0, 0, lineLength, lineWidth,
        0, 0, lineWidth, lineLength)
    // 右上
    drawEdgeLine(canvas, right - lineLength, top,
        0, 0, lineLength, lineWidth,
        lineLength - lineWidth, 0, lineLength, lineLength)
    // 左下
    drawEdgeLine(canvas, left, bottom - lineLength,
        0, 0, lineWidth, lineLength,
        0, lineLength - lineWidth, lineLength, lineLength)
    // 右下
    drawEdgeLine(canvas, right - lineLength, bottom - lineLength,
        lineLength - lineWidth, 0, lineLength, lineLength,
        0, lineLength - lineWidth, lineLength, lineLength)
}

/**
 * 穴が空いているところの4角の1角分の線を書く
 * @param canvas 描画先Canvas
 * @param left 角の左上座標
 * @param top 角の上座標
 * @param left1 線1の左
 * @param top1 線1の上
 * @param right1 線1の右
 * @param bottom1 線1の下
 * @param left2 線2の左
 * @param top2 線2の上
 * @param right2 線2の右
 * @param bottom2 線2の下
 */
private fun drawEdgeLine(canvas: Canvas, left: Int, top: Int,
                         left1: Int, top1: Int, right1: Int, bottom1: Int,
                         left2: Int, top2: Int, right2: Int, bottom2: Int) {
    paint.reset()
    paint.color = 0xffffffff.toInt()
    // 線を描画する
    val rect = Rect()
    rect.left = left + left1
    rect.top = top + top1
    rect.right = left + right1
    rect.bottom = top + bottom1
    canvas.drawRect(rect, paint)
    rect.left = left + left2
    rect.top = top + top2
    rect.right = left + right2
    rect.bottom = top + bottom2
    canvas.drawRect(rect, paint)
}

できあがり

最後に作成したBitmapをonDrawメソッドで描画するとできあがりです。

override fun onDraw(canvas: Canvas) {
    paint.reset()
    canvas.drawBitmap(bitmap, 0f, 0f, paint)
}

できあがり
できあがり

求人

リサーチ・アンド・イノベーションでは例えばCanvasを使ってまで細部にこだわりたいAndroidアプリエンジニアを募集しています。

リードAndroidアプリエンジニア(Forkwell)

リードAndroidアプリエンジニア(Paiza)

Github の mention を Slack に通知する仕組みを作った part1

概要

リサーチ・アンド・イノベーションの浜田(hamadu)です。 最近、社内では CODE のインフラを さくらのクラウド から Google Cloud Platform へと移行する流れが進んでいます。その一環ではないのですが、Google Clound Functions を使って遊んでいたところ、便利な仕組みが生えたので紹介します。GitHub の Issue や Pull Requestに mention(@(GitHubのユーザ名)) が来た時に、その人に向けて Slack で通知するというものです。

これがなぜ必要かというと、弊社の開発メンバーでは GitHubのユーザ名とSlackのユーザ名が異なる人が多く、公式の GitHubアプリで mention を上手く通知する機能が無いためです。また、自前で作れば Slack に送るタイミングやメッセージを自由にカスタマイズできる、というメリットもあります。

大まかな仕組みは以下の図のとおりです。

f:id:r-n-i:20180814175755p:plain

  • (1) リポジトリのIssueやPull Requestにコメントがあると、登録しておいた Webhooks により Google Cloud Functions が走る
  • (2) Google Cloud Functions ではメッセージのパースをし、必要に応じて @username の部分を Slack 向けに書き換える。作ったメッセージを Incoming Webhook の URL に飛ばす
  • (3) Slack の指定のチャンネルに向けてメッセージが飛ぶ

以下、順番に作成方法を解説します。長くなってしまうので2パート構成で、本稿は前半部分を説明します。

  • part1: GitHub Webhooks 経由で Google Cloud Functions が発火する仕組みを作る(上図の (1))
  • part2: Google Cloud Functions の中身を作り込み、Slackにメッセージを飛ばせるようにする(上図の (2) と (3))

Google Cloud Functions を使ってみる

まず、GitHub Webhooks の受け先である Google Cloud Functions を設定します。 開発環境のインストールおよび関数のデプロイ方法は 公式チュートリアル にあるので割愛します。

Google Cloud Functions は JavaScript(node) で記述する必要があります。中身はほぼ空で大丈夫ですが、ここではリクエストオブジェクトのヘッダ、および本文を console.log() で出力させています。

exports.githubToSlack = (req, res) => {
  console.log(req.headers);
  console.log(req.body);
  res.status(200).end();
};

この関数をデプロイすると、関数を発火するためのURLが発行されます。URLは次のような形になっているはずです。(これはデプロイ後、Google Cloud Functions の関数詳細画面 にも表示されます)

https://(リージョン名)-(プロジェクト名).cloudfunctions.net/githubToSlack

ためしに curl で関数を叩いてみましょう。今回は、Content-Type: application/jsonJSON が飛んでくるように webhooks を設定するので、適当な JSON文字列 を送ってみます。

> curl -X POST -H "Content-Type:application/json"  -d '{"test":"hoge"}' (関数のURL)

Google Cloud Functions のログを見て、ヘッダと本文が出力されていれば成功です!

GitHub Webhooks

GitHub Webhooks とは、github.com 上で起こるイベントを HTTP POST で通知する仕組みです。これを Repository や、Organization に対して設定できます。どのイベントに対して発火してほしいかが個別に指定できるので、ここでは Issue, Pull Request に対して発火するように指定します。

連携したいリポジトリの設定画面を開いて、「Add webhook」を押すと設定画面が開くので、以下の様に設定します。特に、Secret は「リクエストが確かにwebhooks経由である」ことを cloud functions に識別させるのに使います。一度設定すると再表示されないので、値をメモっておきましょう。

項目 設定値
Payload URL cloud functionsのURL
Content type application/json
Secret 文字列
Which events...webhook? Let me select individual events.
Issues, Pull Requestsを選択

ここまでできたら一旦テストしておきます。設定したリポジトリで、Issue や Pull Requestを実際に作ってみましょう。Google Cloud Functions の Console にログが出ていれば成功です。

リクエストを検証する

さて、今のままでは URL さえ分かってしまうと何処からでも関数が呼べてしまうので、先程設定した webhook 経由でのみ関数が実行されるようにします。

GitHub は webhook を送る際、リクエスト本文の HMACX-Hub-Signature というヘッダに付与してくれます。この HMAC の秘密鍵となるのが、先程設定した Secret なわけですね。ということでリクエストが正しいか調べるには、同じアルゴリズムを用いて HMAC を計算しヘッダと一致しているか調べればOK。具体的なアルゴリズムGitHubのドキュメント に書かれています。

HMACの計算及び比較を JavaScript で実行するには cryptocreateHmac関数、およびセキュアな比較を行うため secure-compare があればいいでしょう。以下は実装例です。

const crypto = require('crypto');
const secureCompare = require('secure-compare');

function validateRequest(req) {
  const cipher = 'sha1';
  const signature = req.headers['x-hub-signature'];
  const hmac = crypto.createHmac(cipher, '<Secretで設定した文字列>')
    .update(req.rawBody)
    .digest('hex');
  const expectedSignature = `${cipher}=${hmac}`;
  return secureCompare(signature, expectedSignature);
}

これでコア部分の実装はできました。しかし、まだ改善できる箇所があります。Secretを直接ソースコードに記述するのをやめて、環境変数に逃しましょう。Cloud function 内で環境変数を使うには、

process.env['VARIABLE_NAME']

のようにします。これは、nodeプロセスで環境変数の値を得る通常の方法です環境変数に値を入れるには、

> gcloud beta functions deploy FUNCTION_NAME --set-env-vars VARIABLE_NAME=xxx

とします。詳しくはドキュメント Using Environment Variables | Cloud Functions Documentation を読んでください。ここでは、GITHUB_SECRET という環境変数を使うことにします。

最終的なコードは次のようになりました。

const crypto = require('crypto');
const secureCompare = require('secure-compare');

function validateRequest(req) {
  const cipher = 'sha1';
  const signature = req.headers['x-hub-signature'];
  const hmac = crypto.createHmac(cipher, process.env['GITHUB_SECRET'])
    .update(req.rawBody)
    .digest('hex');
  const expectedSignature = `${cipher}=${hmac}`;
  return secureCompare(signature, expectedSignature);
}

exports.githubToSlack = (req, res) => {
  if (!validateRequest(req)) {
    return res.status(403).send('wrong signature.');
  }

  console.log(req.headers);
  console.log(req.body);
  res.status(200).end();
}

ここまで、うまく動いているかテストしておきましょう。Issue および Pull Request を作ると正しく発火すること、curl で適当なヘッダを付けると落とされることを確認しておきます。

まとめ

長くなってしまいましたが、GitHub <=> Google Cloud Functions 連携の基本的な手順は以上です。ここまでくれば、あとはリクエストの中身を見て、Slack に送る具体的なメッセージを構成するだけになります。これは後日、 part2 にて説明します。

リサーチ・アンド・イノベーションでは、プロダクト開発プロセスそのものをハックしていけるエンジニアを募集中です。

からご応募お待ちしております。

Clojureでブログやスマホアプリを作ってみる part2

こんにちは。リサーチ・アンド・イノベーションの小川(J-ogawa)と申します。

弊社サービスの「あなたの日常を、もっといい日常に変える」そんなアプリCODE
iPhoneアプリおよびサーバサイドの開発を担当してます。

本記事はシリーズ物で恐縮ですが、Clojureでブログやスマホアプリを作ってみようのコーナー、2回目となります。
前回 Clojureでブログやスマホアプリを作ってみる part1

基本方針

フレームワークre-frameを使用しています。
re-frameはとてもイケてるフレームワークだと思います。
Elmもre-frameに影響を受けています。

解説記事予定

全4回に分けて解説を行いたいと思います。

  1. re-frameの概要
  2. re-frameのdispatch時の副作用について
  3. サーバサイド
  4. re-frameでiOS & Androidアプリを作る

前回はre-frameについての概要を解説しました。
今回は、re-frameにおけるイベントについての考え方についてご紹介したいと思います。

dispatch(イベント)における副作用の有無

前回 dispatch -> update db(画面表示用ステート) -> subscribe -> 画面更新 と言う話をしました。
ここについてもう少し詳しく見ていきます。

ここでいうdbは画面表示用の単一のステートを指します

re-frameを使用すると否が応でも処理中の副作用について意識的になるように開発を進めることになります。
ここでいう副作用とは、dbの変更以外の事柄を指します。
まるで「dbを内界」、「それ以外を外界」としているような印象を私は受けました。

副作用なしの場合

dispatchによって、dbを変更します。それだけだと非常に簡単です

その場合、reg-event-dbを使います。

(re-frame/reg-event-db
 :switch-editor-mode
 (fn [db [_ mode]]
  (assoc-in db [:editor-mode] mode)))

第一引数:switch-editor-modeはこのアクションの名前です。
第二引数(fn [db [_ mode]] (assoc-in db [:editor-mode] mode)では「現状のdbアクションに渡された情報を引数にして変更後のdbを返す関数」を設定します。

この例は「editor-modeキーの値を引数modeの値にしたdb」を返すようにしています。単純ですね。

副作用ありの場合

例えば、loginのような処理を考えます。

この場合、処理の流れは以下となります。

  1. /loginにhttp POSTして返却値としてaccess_tokenを得る
  2. access_tokenをlocalStorageに保存
  3. /authaccess_tokenを投げて認証する
  4. dbをログイン状態に変更

これはdbを書き換える動作の前に3回の副作用が入っています。

loginで行う一連のコードを追っていく

副作用込みでdbに影響を与える操作はreg-event-fxを使います。

loginアクションのコードは以下です。
ちょっといきなりゴテっとしたコードが出てきますが、「httpを投げて、成功|失敗ならどうする」といった内容です。
(この後も流れに沿ってぽんぽんとコードが登場しますが、細部より流れを追っていただけるとと思います)

(re-frame/reg-event-fx
 :login
 (fn [{:keys [db]} _]
  (let [{email :email password :password} (:user-form db)]
   {:http-xhrio {:method          :post
                 :uri             "/login"
                 :params          {:email email :password password}
                 :format          (ajax/json-request-format)
                 :response-format (ajax/json-response-format {:keywords? true})
                 :on-success      [:login-success]
                 :on-failure      [:sign-error]}})))

({:keys [○]}destructuringと言う便利な変数バインド機能です。最初は取っつきにくいので読み飛ばしてください。値のdbキーの中身をdb変数にバインドしています)

db内のデータ(user-formのemailとpassword)をパラメータとしてPOSTして、成功したらlogin-successアクションをディスパッチします

次のステップlogin-successを見てみます
これも、副作用を伴ってdbを書き換える関数なので、reg-event-fxです。

(re-frame/reg-event-fx
  :login-success
    (fn [_ [_ {:keys [token]}]]
        {:store-token-localstrage token
         :dispatch [:auth]}))

これの意味は、store-token-localstrageアクションをやった後に、authアクションを行うと言う意味です。
2つのアクションを順に見ていきます。

store-token-localstrageはdbに影響を与えないシンプルな副作用(?)なのでreg-fxを使います。

(re-frame/reg-fx
  :store-token-localstrage
  (fn [token]
    (.setItem (.-localStorage js/window) :token token)))

JavaScriptっぽい部分が出てきました。単純に渡されているtokenをlocalstrageの:tokenキーに保存しています。

次のステップauthを見ます。これもまだ副作用を伴う処理です。GET /authしてます。

(re-frame/reg-event-fx
  :auth
  [(re-frame/inject-cofx :token)]
  (fn [{:keys [token]} _]
    {:http-xhrio (-> {:method          :get
                      :uri             "/auth"
                      :on-success      [:auth-success]
                      :on-failure      [:auth-error]}
                     wrap-default-http
                     (wrap-token-http token))}))

成功したら:auth-successアクションをします。ようやく副作用なしのreg-event-dbが来ました。

(re-frame/reg-event-db
  :auth-success
  (fn [db [_ res]]
    (-> db
        (assoc-in [:auth] true)
        (assoc-in [:show-login-modal] false))))

dbのデータをログイン後として、画面が書き換わります。
以上の流れを改めて書くと以下になります。

  • login (副作用あり)
    • login-success (副作用あり)
      • store-token-localstrage (副作用あり)
        • auth (副作用あり)
          • auth-success (副作用なし)
          • auth-error
    • sign-error

re-frameではこのように一連の流れを一つ一つに分けること、副作用とdb(画面更新)の処理を明確に区別することを意識させられます。

db外のものを扱う - 副作用の注入(?)

また、authアクションではaccess_tokenを処理に使用していますが、これはlocalStorageにあるものなのでdb外となります。
そういったものを扱う場合は副作用を注入するという考え方をします。
もう一度authアクションを見てみましょう。

(re-frame/reg-event-fx
  :auth
  [(re-frame/inject-cofx :token)]
  (fn [{:keys [token]} _]
    {:http-xhrio (-> {:method          :get
                      :uri             "/auth"
                      :on-success      [:auth-success]
                      :on-failure      [:auth-error]}
                     wrap-default-http
                     (wrap-token-http token))}))

この関数の3行目、inject-cofxがそれに当たります。(coはcoeffect:副作用)
外界情報入手用の関数 token を使用して、外界(localStorage)からtokenをこの世界(db)に注入しています。(http headerにtokenをセットしています)

関数 tokenはこんな感じです。localStorageから値を引っ張って来てます。

(re-frame/reg-cofx
  :token
  (fn [coeffects _]
    (assoc coeffects :token (.getItem (.-localStorage js/window) :token))))

と、以上loginの一連の流れとソースを見て来ました。

こう言ったように、re-frameのイベントはdbの内外を非常に意識させるAPIとなっています。

処理を細かく分解して書いていくのはなかなか大変だったりもしますが、こうやって一つ一つが小さくなったものを見るととても追いやすいと感じます。
なにより、こうやって書かざるを得ないので、表現がぶれることが少ないと感じます。

まとめ

re-frameにおけるdispatch(イベント)における副作用の有無について今回は見ていきました。

シリーズもので恐縮ですが、次回はサーバサイドについてざっくりとした解説をしたいと思います。
そしてスマホアプリへ・・


リサーチ・アンド・イノベーションでは、Clojureに限らず技術に興味のあるエンジニアを募集しています。 ご応募お待ちしております。

参考URL

Exponential backoff を実例で理解する

 RNI dev の K.oshima です。 network mobility が好きです。

 弊社のサービス Mycomment はユーザーのアクセス解析に Woopra と言うサービスを利用しています。 先日、Mycomment と Woopra のデータコレクター間の通信が失敗した結果、ログ監視にエラーが大量に上がってくる事象がありました。

 失敗理由は特定データコレクターまでの経路消失です。

 どうすればよかったかというと、一度目の通信失敗後にリトライして欲しかった。リトライすればハズレを引かずに成功する可能性があった。Woopra SDK がリトライしてくれればよかった話なのですが、実際にはリトライ機能がないため SDK を利用する利用者(rni-dev)が失敗したらリトライするように書く必要があります。

 この待ってリトライするときに、一般によくある実装ではリトライが失敗するたびにリトライ間隔を長くしていきます。 Railsで良く使われる Sidekiq や先日紹介したfaktory でも同じロジックがリトライ制御に使われています。 Mycomment のリトライも同様に実装したいと思います。
 なぜそうするのでしょう。

イーサネットの例

 きっかけはアプリケーションからの HTTP の利用なのでレイヤー7の話ですが、これからレイヤー1の話をします。 (注意点:レイヤー1の話がそのままレイヤー7にも通じる訳ではないので、そこは分けて考えてください)


 古いイーサネット(Ethernet)は 1本のケーブルに複数の機器をつないで通信する実装です。 (今一般的な 1000BASE-T とは違い昔は本当に電線1本だったんです。) AさんもBさんもCさんも同じ1本のケーブルで通信します。

 糸電話を想像してください。タコ糸に紙コップを結ぶあれです。

 二人だけで糸電話で話すと「もしもし」「どうぞ」と話す側と聴く側に別れて通信します。役割分担がうまくいけば、(ランチにカレーを食べに行こう! などの) 通信は成立します。

 3人以上で糸を共有(分岐)して糸電話で話すと、もしAさんとBさんが同時に喋った場合は、同じ糸を分岐して聞いていたCさんはAさんとBさんの声が同時に聞こえて何をしゃべっていたのか聞き取れなくなります。

CSMA/CD

 同時に喋ることがあってもうまいこと通信を成立させるルールのひとつが CSMA/CD (Carrier Sense Multiple Access with Collision Detection) です。 (電線の場合は、同時に喋っている人がいるかいないか通信の有無が電圧でわかります。) 1. データを送りたい人はまず受信してみる。
Aさん「(今は誰も話してないぞ)」 2. 他の人が送信中の場合は待つ。 (→ 1. へ) 他の人が送信していない時は、データを送りたい人が送信する。
Aさん「Bさんランチにカレーを食べに行きませんか?」 3. 自分が送信中に、もし他の人も送信しだした時は通信をあきらめて止める。乱数秒待った後に再度送信をトライする。
Aさんが話している途中にCさんも話し出したら、AさんもCさんも黙る。そしておのおのが乱数秒待つので、次にAさんとCさんが話し出すタイミングはズレるはず。
さらに衝突した場合は、n回目のリトライの時は 0 から 2**n の範囲の待ち時間からランダムに一つ選んで待つ。混んでいると待ち時間が伸びる傾向になる。

 下手をすると、誰も送信していない瞬間をずっと待つ必要がありますがいつかは送信できます。こんな簡単なルールでたいていの場合はうまくいきます。

CSMA/CA

 他のルールとして、無線 LAN で利用されている CSMA/CA (Carrier Sense Multiple Access/Collision Avoidance) もあります。 1. データを送りたい人はまず受信してみる。
Aさん「(今は誰も話してないぞ)」 2. 他の人が送信していない時は、データを送りたい人が送信する。
Aさん「Bさんランチにカレーを食べに行きませんか?」 3. 他の人が送信している時は、その人の送信が終わるのを待ってすぐに自分が送信すれば、他に送信の順番を待っている人と同時に送信する可能性がある。
BさんもCさんも喋りたい時に、Aさんの話が終わるのを待って終わった瞬間に話し出すとかぶる可能性が高い。他の人の送信が終わって、乱数秒待ってから送信を開始すると他の誰かとかぶりにくい。

 どちらも簡単なルールで、全員が待ち状態になるデッドロックも起きないうまいやり方です。ただ、5人や10人が1本のケーブルを利用しているすいている時はうまくいっても、100人1000人が1本のケーブルで話そうとすると大渋滞になります。待ってる時間が長くなって、送信できる機会が減るので、通信速度が遅くなったように見えます。

乱数と待ち時間の延長

 待ち時間を決めるのに乱数の要素があるのはなぜか。もし乱数がなければ、1本のケーブル(リソース)を100人が一斉に利用しようとすると、1人が利用開始して残り99人が待ちます。99人は同じ時間待つので次回送信しようとした際に99人の競合になります。乱数で待ち時間が変われば、100人の各々のタイミングがずれてリトライの際に他の誰とも送信がかぶらない可能性があります。リトライの回数が減って待ち時間の合計が減って結果としてスループットが上がる可能性があります。
 待ち時間をリトライのたびに長くするのはなぜか。1本のケーブルを複数人で利用するときに空いてくるのを待つための工夫です。一つのリソースを大勢で使うためにはゆずり合う必要があります。ゆずり合うための工夫です。


 ここまでの話はざっくりとしたレイヤー1の話です。これがそのまま HTTP 経由のサーバーとクライアントの関係に当てはまるわけではありませんが、(現実には Woopra のデータコレクターは複数 IP ありますし、1個の IP あての通信をロードバランサーで受けて裏側の複数のサーバに負荷分散しているはずです。) 1. 混雑を避ける、2. リトライする 要素は共通です。

メール送信の例

 待ち時間をリトライのたびにだんだん長くするテクニックは、email の送信にも使われています。宛先を間違えたメールを送信した場合、SMTP サーバがなんども送信を試して最終的に設定されたリミット(2日など)に引っかかり諦めて送信者に MAILER-DEAMON からの不達メールが返ってくることがあります。SMTPサーバ(sendmail, postfix 等)が再送を試す際にもリトライまでの待ち時間をだんだんと長くする技が使われています。受け手のサーバの処理が溢れてパンクしている時にも送信者(ユーザー)の操作なしに SMTPサーバの陰の働きにより送信可能になる時を待ちメールを配達する工夫です。

 以下は、postfix のマニュアルから引用です。

$ man 8 qmgr

http://www.postfix.org/qmgr.8.html

   exponential backoff
         Mail  that  cannot  be  delivered  upon  the  first  attempt  is
         deferred.   The  time interval between delivery attempts is dou-
         bled after each attempt.

TCP プロトコルの例

 リトライのたびに待ち時間を長くするアルゴリズムを Exponential back off と呼びます。

 TCP/IPTCP でも Exponential back off は使われています。

 以下は NetBSD 5 での TCP 実装ですが、TCP のパケットが届かず再送を要求した時に、 tcp_backoff を引いて再送待ち時間 t_rxtcur を伸ばしていくところです。

https://github.com/IIJ-NetBSD/netbsd-src/blob/458bd6ba699726d5269e01d428ee889762fcffbf/sys/netinet/tcp_timer.c#L384

/*
 * Retransmission timer went off.  Message has not
 * been acked within retransmit interval.  Back off
 * to a longer retransmit interval and retransmit one segment.
 */

if (++tp->t_rxtshift > TCP_MAXRXTSHIFT) {
  tp->t_rxtshift = TCP_MAXRXTSHIFT;
  TCP_STATINC(TCP_STAT_TIMEOUTDROP);
  tp = tcp_drop(tp, tp->t_softerror ?
      tp->t_softerror : ETIMEDOUT);
  goto out;
}
TCP_STATINC(TCP_STAT_REXMTTIMEO);
rto = TCP_REXMTVAL(tp);
if (rto < tp->t_rttmin)
  rto = tp->t_rttmin;
TCPT_RANGESET(tp->t_rxtcur, rto * tcp_backoff[tp->t_rxtshift],
    tp->t_rttmin, TCPTV_REXMTMAX);
TCP_TIMER_ARM(tp, TCPT_REXMT, tp->t_rxtcur);

 ここの計算。

TCPT_RANGESET(tp->t_rxtcur, rto * tcp_backoff[tp->t_rxtshift],
        tp->t_rttmin, TCPTV_REXMTMAX);

 tcp_backoff

https://github.com/IIJ-NetBSD/netbsd-src/blob/458bd6ba699726d5269e01d428ee889762fcffbf/sys/netinet/tcp_timer.c#L294

const int  tcp_backoff[TCP_MAXRXTSHIFT + 1] =
    { 1, 2, 4, 8, 16, 32, 64, 64, 64, 64, 64, 64, 64 };

タイムアウトのたびに大きくなり 64 で上限にぶつかる数字になっています。

 注釈: 意外に思われるかもしれませんが、TCPの実装は時代によって変化しているので、OSの種類やバージョンによってはこの再送待ちの挙動は異なります。

QUIC プロトコルの例

 TCP の欠点をつぶそうとしている新しいプロトコル QUIC の例として、 Google Chrome の QUIC 実装を見てみましょう。
 パケット送信する部分で、再送待ちタイマーの値を計算するところ。 consecutive_rto_count_ 分再送の待ち時間を長くしていきます。
https://chromium.googlesource.com/chromium/src/net/+/master/quic/core/quic_sent_packet_manager.cc#830

// Calculate exponential back off.
retransmission_delay =
retransmission_delay *
(1 << std::min<size_t>(consecutive_rto_count_, kMaxRetransmissions));

 このように Google の QUIC プロトコルでも exponential back off アルゴリズムは使われています。

ここに使いたい

 発端となった Woopra SDK で再送信する時に待ち時間を伸ばす理由は、メール送信と同じです。
 相手が受け取れなかったのなら再送信したい。しかし、即再送信しても相手のサーバ負荷が原因だった場合は再び受け取れない可能性が高い。少し待ってから再送信したい。その「少し待って」の時間はどうやって決めるの? 失敗する間はリトライの待ち時間を徐々に伸ばしていって、相手が受け取れるタイミングを待つために exponential back off を使います。
 自サイトの(Railsアプリごとの)複数の送信が相手の負荷を再び高めないように、乱数秒追加して再送タイミングもばらけさせるとなお親切ですね。通信相手にも負担にならないし、失敗の原因を一つ減らすので自分のサービスにもプラスです。

お待ちしております

 上のような話に興味を持たれる方。そんなの知っているよ、 Linux 4.15 では違うよ。どちらの感想を持つ方も RNI ではお待ちしております。

イミュータブルデータモデルへの取り組み with Ruby on Rails

こんにちは。リサーチ・アンド・イノベーションの中村(konk303)と申します。
いわゆる「railsおじさん」的な立場で、主にサーバーサイドの開発をしています。

Introduction

本稿ではQiitaのイミュータブルデータモデルと webアプリケーションにおける現実解にインスパイアされて、弊社でのイミュータブルデータへの取り組み(とその苦しみ)を紹介したいと思います。 qiita.com

イミュータブルデータモデルとは?

まるっと引用。
イミュータブルデータモデルと webアプリケーションにおける現実解 - Qiita

詳細はリンクに譲りますが、「履歴を全て残すようなデータ設計にし、 UPDATE を廃することで情報の追跡可能性を確保、堅牢な設計にする」モデリング手法です。 原則この手法に従うと、そうそう汚いモデルにはならないという優れもの(雑)

です。イベントが起こる度に新規レコードを積む方式。

実現したいこと

あるユーザーの特定イベント発生当時の属性情報を保存・閲覧可能にする

弊社のアプリCODEにはレシート・バーコードのスキャンを使った買物登録機能が、
またアンケートプラットフォームのMycommentにもアンケートに回答する機能があるのですが、
どちらも 「買物登録・アンケート回答当時のユーザー属性情報を使ってデータ分析をしたい」というオーダーが強くあります。
あるユーザーの2年前の買物は、現在の年齢でなく2年前当時の年齢でグループ化して分析したい、と。

これの実現のために、「ユーザー属性の情報を変更の度に履歴として保存する」仕組みにしています。

あまり重要視していないメリット

一方でこの辺はあまり意識していません。

UPDATEが廃止でき、レコードのsaveが素直になる

これが本来一番のメリットなのですが、 rails (というかrailsについてくるどんなテーブルでもPKはid:intに決まりな割り切り) に身を任せていると、普段それほどツラくないところではあります。

履歴が全て残せる

これも大きなメリットではあるのですが、 結局履歴が全部あるが役に立つ場面はそれ程出てこない印象です。

ユーザーサポートで、特定のユーザーがいつからいつまで問題のあったバージョンのアプリを使っていたか みたいな時に使われる程度。

実装

今回は単純にユーザーと住所(都道府県)の関係で説明します。

  1. ユーザーは、会員登録時に自分の都道府県を登録する
  2. ユーザーは、(引っ越しにより) 自分の都道府県を随時変更する


よく出てくるオーダーとしては

  1. 特定イベント(買物登録・アンケート回答)に紐づくイベント当時の住所を使ってデータ分析をしたい (使用量多い)
  2. 今現在東京に住むユーザーを抽出したい (該当ユーザーにメッセージを表示したり) (使用量非常に多い)
  3. 特定ユーザーの属性遷移履歴を確認したい (たまにのオーダー)


履歴を保持する必要は絶対にあるのですが、「大抵の場合必要なのは最新値のみ」も一方では現実で、 どう両立させるかを試行錯誤しています。 使用用途が分析なので、sqlレベルで解決したいという気持ちもあります。

住所1カラムならUserにcurrent_prefectureをキャッシュ値として保存してしまえば良いのですが、 実際の属性は何項目もあってキリがない状態です。

パターン1: まじめにイミュータブルにする・「最新の状態」はsubqueryで取得

subqueryを使って該当ユーザーの現在の住所を取得するassociationを追加で定義しています。 user.current_addressでユーザーの現在の住所が取得できます。

modelの使い勝手としては非常に良いのですが、 データ数が増えると加速度的に遅くなるという重大な問題を抱えています (実際困ってます 😓 )。

class User < ActiveRecord::Base
  # id: integer
  has_many :user_addresses
  has_one :current_address, -> { latests }, class_name: 'UserAddress'
  has_many :purchase_events
end

class UserAddress < ActiveRecord::Base
  # id: integer
  # user_id: integer
  # prefecture: string
  belongs_to :user
  scope :latests, -> { where(id: unscoped.group(:user_id).select(arel_table[:id].maximum)) }
end

class PurchaseEvent < ActiveRecord::Base
  # id: integer
  # user_id: integer
  # user_address_id: integer
  belongs_to :user # strictにやるなら `belongs_to :user, through: :user_address`かも
  belongs_to :user_address
end

お題: 現在東京に住むユーザー一覧

User.joins(:current_address).where(user_addresses: { prefecture: 'tokyo' })
=> "SELECT `users`.* FROM `users` INNER JOIN `user_addresses` ON `user_addresses`.`user_id` = `users`.`id` AND `user_addresses`.`id` IN (SELECT MAX(`user_addresses`.`id`) FROM `user_addresses` GROUP BY `user_addresses`.`user_id`) WHERE `user_addresss`.`prefecture` = 'tokyo'"

お題: 特定の買物登録の登録時のユーザー住所

PurchaseEvent.find(12345).user_address.prefecture

パターン2: 「最新の状態」と履歴でテーブルを分ける・最新の状態更新時に履歴側も書き込み

これは全然イミュータブルじゃないですね…。 ですが、履歴を残すこそが実現したいことの場合は、結局コレが一番見通しがよく感じてます。

after_saveのcallbackにロジックを隠せたことで 「実際のビジネスロジックに履歴保存にまつわるノイズを残さない」と 「間違いなく1transaction内でデータの更新と履歴の登録を行う」が実現できてます 😎。

class User < ActiveRecord::Base
  # id: integer
  has_one :user_address
  has_many :user_address_logs, through: :user_address
  has_many :purchase_events
end

class UserAddress < ActiveRecord::Base
  # id: integer
  # user_id: integer
  # prefecture: string
  belongs_to :user
  has_many :user_address_logs

  after_save :create_log!

  private

  def create_log!
    user_address_logs.create!(slice(:prefecture)) if changed?
  end
end

class UserAddressLog < ActiveRecord::Base
  belongs_to :user_address
  has_one :user, through: :user_address
end

class PurchaseEvent < ActiveRecord::Base
  # id: integer
  # user_id: integer
  # user_address_id: integer
  belongs_to :user # strictにやるなら `belongs_to :user, through: :user_address_log`かも
  belongs_to :user_address_log
end

お題: 現在東京に住むユーザー一覧

User.joins(:user_address).where(user_addresses: { prefecture: 'tokyo' })
=> "SELECT `users`.* FROM `users` INNER JOIN `user_addresses` ON `user_addresses`.`user_id` = `users`.`id` WHERE `user_addresss`.`prefecture` = 'tokyo'"

お題: 特定の買物登録の登録時のユーザー住所

PurchaseEvent.find(12345).user_address_log.prefecture

まとめ

全然イミュータブルデータモデルの話じゃなく 「履歴を保存しつつ現在の状態をサクッと取得するのにどう苦労しているか」の話になってしまいましたが、 良い設計とパフォーマンスとのバランスを取るべく毎日試行錯誤しています。

リサーチ・アンド・イノベーションでは、railsを乗りこなしてやりたいことをサクッと実現していくエンジニアと
「そんなのは不毛だ・イミュータブルこそ未来だ・今すぐclojuredatomicに移行しよう」と
熱く説得してくれるエンジニアの両方を募集しています 😏。
こちら からご応募お待ちしております。

参考

次世代バックグラウンドジョブシステム Faktory を試す

リサーチ・アンド・イノベーションの浜田(hamadu)と申します。 いつものお買い物がちょっとお得に、家計簿にもなるポイントアプリ「CODE」のサーバサイド、およびAndroidアプリの開発を担当しています。

CODE ではバックグラウンドジョブシステムとして、Sidekiq を採用しています。その作者、Mike Perham 氏が新しい仕組みを作っていました。その名も Faktory。Sidekiq と違いワーカーが言語に依存せず、また本体はGoで書かれているためスケールするのがウリのようです。まだまだ開発中で、プロダクションで使うには厳しい印象を受けますが、今後Sidekiqを置き換えうるプロジェクトになるのではと思います。

本稿では簡単に仕組みの紹介をして、Rubyでジョブを投げるClientと、ジョブを処理するワーカーをそれぞれ実装してみました。 ソースコードfaktory-ruby-sample にあります。「御託はいいから試したい」という人は README.md に従って手を動してみてください。

仕組み

一般にバックグラウンドジョブシステムは非同期に処理したいお仕事(本稿では ジョブ と表記)を受け付けて管理するサーバと、それらを処理するワーカーから構成されています。ワーカーは一般に複数あり、並列で仕事ができるようになっています。

f:id:r-n-i:20180214163243p:plain

ジョブがClientから飛んでくると、まずタスクキューに溜まります。ワーカーは暇な(処理しているジョブが無い)時、サーバに対してリクエストを投げ、キューに溜まっているジョブを取ります。

優先度、項目別にキューイング

Faktoryは(Sidekiqもですが)ジョブの種類ごとにラベル(優先度)が付けられるようになっています。ワーカーは処理するジョブのラベルを限定できるので、例えば優先して処理したいジョブは複数のワーカーで、そうでもないジョブは暇な時に適当なワーカーで、といったことができます。上の図で言うと Task Queue に当たるものが複数あり、各ワーカーが取得するキューを選べるように設定できるイメージです。

ジョブのリトライ

ジョブの実行に失敗した場合、そのジョブはリトライ用のキューに積まれます。各ジョブごとにリトライできるまでの待機時間が与えられ、しばらく待たないと再実行できません。さらに、この時間は指数的に増えていきます。

Sidekiqと比べて何が良いか

外部に依存しないデータストア

Sidekiq はジョブを管理するデータストアとして Redis に依存しており、プロセスの起動に動いている Redis が必須でした。Faktory は 内部で RocksDB というkey-valueストアを用いていて、本体は1バイナリを走らせるだけで完結します。運用が楽ですね。

言語に依存しないワーカー

Sidekiq は Rubygem として提供されていて、良くも悪くも Ruby に依存していました。Rails との相性はいいのですが、他の言語からだと原則使えないのが残念です。Faktoryのサーバは規定の形式でTCP上で喋るようになっているので、どんな言語でもワーカーが書けます。公式では現在 RubyGo が提供されていますが、他にもサードパーティ製のワーカー が公開されています。

使ってみる

早速使ってみます。今回はRubyで、公式で提供されている gem を使ってバックグラウンドジョブを書いてみます。例としてURLの文字列を引数として受けると、そこへGETリクエストを投げ、結果を標準出力に書き出すものを作ってみます。

Faktoryのインストールと起動

Docker image が提供されている ので、それをまるっと使わせてもらいましょう。

# Docker imageをpullして
$ docker pull contribsys/faktory

# 起動させます。これはデバッグ用で、インメモリDBを使う設定になっています。
# 本番で使うときはジョブを保存するデータのパスを指定します。
$ docker run --rm -it -p 127.0.0.1:7419:7419 -p 127.0.0.1:7420:7420 contribsys/faktory:latest

Faktoryはデフォルトで7419、7420のポートを受け付けます。7419番はサーバとやり取りするAPIの入り口、7420番はWeb UIです。

Web UIを見てみる

ブラウザで localhost:7420 にアクセス。すると、Sidekiq を使ったことがある人には馴染み深い画面が表示されます。

f:id:r-n-i:20180214163339p:plain

サンプルジョブとクライアントを書く

早速ジョブとクライアントを書いてみましょう。今回はRailsのプロジェクトの中に組み込むのではなく、生のRubyで実装してみます。公式gemの faktory_worker_ruby を使います。本稿のサンプル実装は faktory-ruby-sample に置いてあります。

ジョブを書く

まず処理したいジョブを記述します。Rubyの場合、Faktory::Job を include した上で本体である perform(*args) を実装します。*args はジョブ の引数です。Sidekiqを使ったことがあれば馴染み深いでしょう。

# fetch_url_worker.rb
require 'faktory_worker_ruby'
require 'open-uri'

class FetchURLWorker
  include Faktory::Job

  def perform(*args)
    puts "Hello, I am #{jid} with args #{args}"

    url = args[0]
    open(url) do |f|
      puts f.readlines.join("\n")
    end
  end
end

jid はジョブの識別子で、後述するクライアントでジョブを投げる時に生成されます。

ジョブを投げるクライアントを書く

次にジョブを投げるクライアントを実装します。先程書いた FetchURLWorkerrequire して #perform_async もしくは #perform_in を呼ぶだけです。 #perform_async は指定のジョブを即実行します。 #perform_in は指定秒数後にジョブを実行します。

# client.rb
require './fetch_url_worker'

# ジョブを即実行
FetchURLWorker.perform_async('https://r-n-i.jp/')

# 1分後にジョブを実行
FetchURLWorker.perform_in(60, 'https://notexist.r-n-i.jp/')

実行してみる

まずは手元でワーカーを起動します。

$ bundle exec faktory-worker -r fetch_url_worker.rb

すると、Web UIの「実行中」のところにプロセスが現れます。

f:id:r-n-i:20180214163349p:plain

この状態で、クライアントのスクリプトを走らせます。

$ bundle exec ruby client.rb

ジョブが2つ作成され、1つは即実行されます。もう1つはキューにたまった状態になり、Web UIの「予定」のところに入ります。

f:id:r-n-i:20180214163410p:plain

ジョブの失敗

遅延して実行される方のジョブはあえて失敗させてます。(URLが存在しません。) ジョブが失敗すると、「再試行」のキューに入ります。

f:id:r-n-i:20180214163427p:plain

暫く経つと再度走りますが、失敗し続けるはずです。その度に試行回数が増えていき、再実行までの時間が長くなるようになっています。このあたりもSidekiqと同じですね。

まとめ

Sidekiqと同じ使用感で回せそうだ、と分かりました。まだ本番運用するには危ないですが、Rubyではない趣味プロジェクトで人柱になってみるのはありかなぁと思ってます。ちなみに、Faktoryはtech系podcast:The ChangeLog で紹介されていたので知りました。Faktoryの回は こちら。バックグラウンドジョブシステムの設計思想、Amazon SQSとの住み分け、Sidekiqの裏話などが聞けるので興味があればぜひ。

リサーチ・アンド・イノベーションでは、プロダクト開発だけでなく、技術の情報収集にも積極的なエンジニアを募集しています。こちら からご応募お待ちしております。

参考URL

Clojureでブログやスマホアプリを作ってみる part1

こんにちは。リサーチ・アンド・イノベーションの小川(J-ogawa)と申します。

iOSアプリの開発とサーバサイドの開発をやっています。

私の記事はClojureがテーマです。

Clojure

なぜClojureなのか。

恐縮ながら個人的趣味に基づきます。Clojureシンタックスが一貫しているのと、表現力が高い所がとても好きです。他にも色々といい点はあると思いますが、好きで採用しています。

数年前にClojure作者のRich Hickeyのプレゼンを見て、感銘を受け、勉強を始めた思い出があります。

まだまだClojureはマイナーで、弊社RNIでも主流はRuby, Ruby on Railsですが、Clojureを採用することも視野に入っていたりいなかったり。

Clojureフレームワークre-frameでブログを作成

Clojureを使ってブログを作成してみました。 フレームワークre-frameというSPAパターンを使用しています。

複数回に分けて、ブログのソースを追いながらClojure(re-frame)の解説を行いたいと思います。 Clojure製アプリのノリを少しでもお伝えできたらと思います。

また、ここから派生してスマートフォンアプリを作ることも予定しています。

ソースはコチラ https://github.com/r-n-i/blog (解説時は20170601タグ地点)

解説記事予定

全4回に分けて解説を行いたいと思います。

  1. re-frameの概要
  2. re-frameのdispatch時の副作用について
  3. サーバサイド
  4. re-frameでiOS & Androidアプリを作る

re-frame

re-frameというSPAパターンを使っています(内部でreactインタフェースのreagentを使用) 公式のdocsがとても充実していて、読み物としてもとても面白いです。

re-frameにおいて、画面表示の流れは以下となります。

dispatch -> update db(単一のデータ) -> subscribe -> update view

今回はこの流れについて解説します。

日本語での情報としては、こちらの解説もとてもわかりやすいです。

では、re-frame製ブログのソースの方を追っていこうと思います。

ビルドツール

Clojureでは、leiningenというビルドツールが主に使われます。 プロジェクト直下のproject.cljにはその設定を書きます。 また、このプロジェクトはleiningen templateのre-frame-templateより生成しています。

lein new re-frame <project-name>

で、re-frame用のプロジェクトの雛形が生成されます。railsでいうrails newです。

起動

作成したブログのソースについては以下手順で起動を試していただけます。

  1. MySQLで、blogデータベースとblog@localhostユーザを作成
  2. git clone https://github.com/r-n-i/blog
  3. cd blog
  4. lein migratus migrate(マイグレーション)
  5. lein figwheel

サーバが立ち上がりhttp://localhost:3449で確認できます。 figwheelは自動でソースリロードしてくれる便利なツールです。

フォルダ構成

以下のようになっています。

├── Procfile
├── README.md
├── project.clj
├── resources
│   ├── migrations
│   └── public
│       ├── css
│       ├── index.html
│       └── vendor
└── src
    ├── clj
    │   └── blog
    │       ├── core.clj
    │       ├── handler.clj
    │       └── server.clj
    └── cljs
        └── blog
            ├── config.cljs
            ├── core.cljs
            ├── db.cljs
            ├── events.cljs
            ├── subs.cljs
            └── views.cljs

srcフォルダの中にソースを書いていきます。

cljフォルダとcljsフォルダがあります。 サーバサイドの方はclj(Clojure)、クライアントの方はcljs(ClojureScript)です。

今回はクライアントサイドについて解説をします。 具体的なコードが続きますが、要点だけ押さえていただければと思います。

クライアントサイド

re-frameはwebページとしてresource/public/index.htmlを返します。

resource/public/index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset='utf-8'>
    <link rel="stylesheet" href="css/bulma.css">
  </head>
  <body>
    <div id="app"></div>
    <script src="js/compiled/app.js"></script>
    <script>blog.core.init();</script>
  </body>
</html>

cljs以下に書いたClojureScriptコードはコンパイルされて一つの.jsファイルになります。 <script src="js/compiled/app.js"></script>でその.jsファイルを読み込み、blog.core.init()を実行して画面をレンダリングしています。

これはsrc/cljs/core.cljsのinit関数を呼ぶ事に相当します。見てみましょう。

src/cljs/core.cljs

....

(defn ^:export init []
  (re-frame/dispatch-sync [:initialize-db])
  (dev-setup)
  (mount-root)
  (re-frame/dispatch [:get-entries])
  (re-frame/dispatch [:auth]))

initにはいろいろ書いてますが、この中のmount-rootがviewを生成しています。

View

src/cljs/core.cljs

....

 (defn mount-root []
  (re-frame/clear-subscription-cache!)
  (reagent/render [views/main-panel]
                  (.getElementById js/document "app")))

....

このmount-root内でレンダリング関数reagent/render<div id="app"></div>の初期DOMに対してview/main-panelレンダリングしています。 (関数がhoge/fugaとなっている場合のhogeはnamespaceです)

画面についてのコードはsrc/cljs/views.cljsです。 大部分はhiccupというhtmlテンプレートで記述しています。re-frameはこのシンプルなhiccupを直感的に使えるのが魅力です。

最下部の関数main-panelを見てください。

src/cljs/views.cljs

(defn main-panel []
  (fn []
    (let [error @(re-frame/subscribe [:error])
          mode @(re-frame/subscribe [:mode])
          show-login-modal @(re-frame/subscribe [:show-login-modal])]
      [:div
       [nav]
       [header]
       (when error [:div.notification.is-warning error])
       (when (= mode :edit) [editor])
       [entries]
       (when show-login-modal [modal])
       ]
      )))

この関数がこのblogの全体部を構成しています。 div以下を見ていただくとなんとなく各パーツが配置されているんだなという感じがすると思います。

Subscribe

main-panelのlet句でsubscribeをしています。

re-frameでは単一のデータを元にUIを管理しています。 そのデータについて、dbと呼んでいます。(紛らわしい気もしますね・・) dbの変化を購読(監視)するのがsubscribeです。それぞれのパーツが購読したイベントに関してだけ、データの変化を受け取ります。

main-panelは:error, :mode, :show-login-modalの3イベントを購読しています。 イベントにはdbから値を返す関数が設定されていて、dbの変化に伴ってイベントが返す値も変化するときに、購読しているパーツに値(を持つatom)を返します。その際にパーツを再描画します。

ちょっとごちゃごちゃ何言ってるかわかりづらいですが、要はmain-panelは再描画される要因となるデータが3つあります

  • db(単一のデータ)のerror部が更新されたら再描画
    • エラー表示|非表示(when error [:div.notification.is-warning error])
  • db(単一のデータ)のeditモード部が更新されたら再描画
    • 記事編集モードの表示|非表示(when (= mode :edit) [editor])
  • db(単一のデータ)のログインモーダル表示モードが更新されたら再描画
    • ログインモーダル表示|非表示(when show-login-modal [modal])

という感じです。

これら子供のパーツ(editorなど)もそれぞれが独自にsubscribeをできます。これによって、動的に変化する部分のスコープを狭くして把握しやすくできます。

次に、記事一覧パーツのentriesを見ていきます。

(defn entries []
  (fn []
    (let [entries @(re-frame/subscribe [:entries])]
      [:div
       (for [entry- entries] ^{:key entry-}
         [entry entry-])])))

これはentriesイベントを購読しています。 記事一覧もdbのentries領域が更新されることでentriesが再描画をして表示されています。

実は、このサイトは画面を一旦描画した後にentriesをAPIで取得して、取得後に画面更新が行われています(サーバサイドレンダリングを行なっていないため)

ブログの更新をしてみると表示で記事が一瞬遅れていると思います。 画面描画後に 記事一覧取得 -> dbのentries部更新が行われています。

このようなdbに変化をもたらす処理はdispatchです。

Dispatch

dispatchについて、上記で紹介したblog.core.init()(最初に呼ばれる関数)をもう一度見てみます。

src/cljs/core.cljs

....

(defn ^:export init []
  (re-frame/dispatch-sync [:initialize-db])
  (dev-setup)
  (mount-root)
  (re-frame/dispatch [:get-entries])
  (re-frame/dispatch [:auth]))

mount-rootでUIレンダリングを行った後、(re-frame/dispatch [:get-entries])というのをやっていると思います。このdispatchが、dbに変更をもたらす処理となります。画面に変更を起こすにはdispatchが必ず必要です。

dispatch -> update db -> subscribe -> update view となります。

src/cljs/views.cljsを見ると、domのon-clickの際にいろいろとdispatchしているのが見てもらえると思います。

というわけで、足早でしたがざっくりとした解説でした。

まとめ

dispatch -> update db -> subscribe -> update view がre-frameの基本的な流れだということをおさえていただければと思います。

シリーズもので恐縮ですが、次回はdispatchとsubscribeについてもう少し詳細に解説したいと思います。

リサーチ・アンド・イノベーションでは、新技術にアンテナを張り、プロダクトの改善にトライするエンジニアを募集しています。こちら からご応募お待ちしております。