mmag

ハマったことメモなど

ニポポタマスを支える技術

こんにちは。

わたくし、勤め先の有志と社内向けの日報投稿Webサービス、ニポポタマス(通称ニポタマ)をやっています。元々は会社の開発合宿でつくりはじめたものですが、会社のみんなが日々の出来事、書きたいことを書ける場として、また僕ら開発者がやりたいことをいろいろ試せる場として、9月半ばから、ざっくり5ヶ月くらい開発・運用してきました。そろそろ使っている言語やフレームワークについて、どんな感じに使っているのか、また思うことなど書いてみます。

Elixir/Phoenix

ニポタマはElixir/PhoenixによるJSON APIサーバをバックエンドとしたSingle PageなWebアプリです。ElixirとPhoenixってなんぞや、という方にはこの動画がおすすめです。

基本的には単にJSONを返すAPIなので、至って普通のPhoenixアプリケーションです。とくにumbrellaプロジェクトにしているわけではなく、mix phoenix.new --no-html --no-brunchから初めています。データベースもデフォルトのPostgresです。なお、このAPIサーバはbodyというコードネームで開発しているので、以降bodyと表記します。Elixirを採用したのは、僕が使いたかったからです。完全に僕の独断で、他の開発メンバーは未経験なので、mix phoenix.servermix edeliver deploy release productionなどの見慣れないコマンドは、npm startnpm run deployでラップしています。

所感

Elixir(Erlang)で書いてよかったなと思っているところは、非同期処理が簡単に書けることと、OTPの力です。

非同期処理

普通のWebアプリで非同期処理というとジョブキューなどを使うことが多いと思いますが、Erlang VMではプロセスをspawnすることで簡単に非同期処理を書くことができます。ニポタマでは、ログイン時の認証にSlackを使っており、ニポタマにおけるアイコンはSlackアイコンと同じものです。アイコン画像を保存しているわけではなく、URLを保持しているのですが、Slackのアイコンを変えたときにリンク切れになってしまうという問題があります。暫定対応として、ログイン時にその人のSlackアイコンURLを保存しなおしています。アイコンURLはOAuthのフローの中で取得できるので改めて取り直すことはしませんし、保存はUPDATEクエリ1回だけなので、とくに時間がかかるわけではありません。ですが、ユーザへのレスポンスはすでに返せる状態にあるのに、こちらの都合でわずかな時間でも待たせるのはどうなんだという建前と、ちょっとかっこいいよねという本音から、非同期に保存をするようにしています。コードとしては以下のように、コントローラの中でTask.start/1を使っています。滅多にないと思いますが仮に失敗してもクリティカルな問題にはならないので、クラッシュしても何もしていません。

defmodule AuthController do
  def login(conn, params) do
    ...

    user = User.get_by(slack_id: slack_id)

    case user do
      nil ->
        need_signup(conn, access_token)
      user ->
        async_update_slack_account(user, %{avatar_url: avatar_url})

        conn
        |> put_status(201)
        |> render(AuthView, "show.json", %{user: user})
    end
  end

  defp async_update_slack_account(user, params) do
    Task.start fn ->
      user.slack_account
      |> SlackAccount.changeset(params)
      |> Repo.update
    end
  end
end

OTP

OTPとは、Erlang VMで動くアプリケーションをつくるときのライブラリやフレームワークデザインパターン集みたいなものです。Phoenix自体もOTPアプリケーションですが、PhoenixErlang VMで動くOTPアプリケーションたちのWebインターフェースとして捉えるべきかなと思っています。ここで言いたいのは、Phoenixの後ろ側に機能を追加することで、Webアプリとしても幅が広がるんですよ、ということです。OTP自体の説明が難しかったりあくまでフレームワークなので「こんなことができるようになります」というのも難しいのですが、例えば先ほど書いたSlackアイコンのURLを更新する実装では、アイコンを変えた後にログインしてくれないと、いつまで経ってもリンク切れのままという問題があります。定期的に任意の処理をするサーバを書けば解決するので、あとでやりたいなと思っています。quantumなどのジョブスケジューラを使ったりしてもいいかな、という気持ちですが、結局quantumも中身を見るとGenServerをつかったOTPアプリケーションです。

他には、昨年末に以下のような集計をして、2016年のまとめのようなページを作ったことがあります。

  • 2016年に投稿された日報の数
  • 2016年に最も「otsu」されたひと(otsuはlikeみたいなものです)
  • 2016年に最も「otsu」したひと
  • 日報投稿から最速で付けられた「otsu」
  • などなど

これらをJSONで返すコントローラはEctoを経由してSQLで集計をしているわけですが、アクセスがある度に集計するのは無駄なので、キャッシュしようと考えるのが自然です。そんなときに、redisやmemcachedを立てなくても、キャッシュしてくれるOTPアプリケーションを導入すれば問題を解決できます。ニポタマではcon_cacheを使いました。一部省略したコードを以下に示します。

defmodule SummaryController do

  def index(conn, %{"year" => "2016"}) do
    data = ConCache.get_or_store(:summary_cache, :summary, fn ->
      %{
        users:                SummaryService.registered_people(),
        nippoes_count:        SummaryService.nippoes_count(),
        otsukares_count:      SummaryService.otsukares_count(),
        longest_nippoes:      SummaryService.longest_nippoes(),
        shortest_nippoes:     SummaryService.shortest_nippoes(),
        fastest_otsukare:     SummaryService.fastest_otsukare(),
        most_otsukared_nippo: SummaryService.most_otsukared_nippo(),
        otsukare_giver:       SummaryService.otsukare_giver(),
        otsukare_getter:      SummaryService.otsukare_getter()
      }
    end)

    render(conn, "index.json", data)
  end

  def index(conn, _opts) do
    conn
    |> put_status(404)
    |> render(ErrorView, "404.json")
  end
end

アプリケーションサーバが1台であったり永続化したいときはそれほど旨味を感じませんが、Erlangクラスタを組んだときなどは複数ホストでキャッシュを共有できる(はず)ので便利かと思います。

Vue.js

ユーザが実際に触れる部分は、Vue.jsで実装されたSPAです。faceというコードネームで呼んでいるので、以降faceと書きます。Vue.jsは、scriptタグでちょいっとCDNから読むだけでも使えて、かつ、ある程度の規模のSPAも実装できるフレームワークと言われています。開発メンバーの@hypermktさんがぜひVue.jsでSPAを試したいということで採用が決まりました。開発初期はv1系でしたが、Vue.js 2.0のリリースから約1週間後には、v2にバージョンアップしていました。SPAとして動かすため、vue-routervue-resourceなどのライブラリも使用しています。vue-resourceは、そろそろaxiosに替える予定です。

所感

学習コストという言葉はあまり好きではありませんが、つくれるものの規模を考えると、学習のコスパは良いと思います。僕が見えていない部分もあると思いますが、それほどフレームワークとして巨大でなく、日本語版のドキュメントもあるので、未経験でもわりとすぐに開発に入れた印象があります。

またvuexは使っていませんが、ニポタマくらいの規模のアプリケーションでも導入してもいいんじゃないかと思っています。公式のドキュメントには中規模から大規模なアプリケーションで導入を検討するとよいということが書いてありますが、APIからデータを取ってきて見せるくらいのSPAで使っても、特に都合の悪い点は無いと思います。ニポタマくらいの規模と言っても伝わらないと思うので、参考までにsrcディレクトリ以下の*.js*.vueの行数は合計約2000行、ルーティングは以下の通りです。

let router = new VueRouter({
  routes: [
    {path: '/', component: FlashMessage, children: [
      {path: 'signup', component: Signup},
      {path: 'profile', component: Profile},
      {path: '', component: Top, children: [
        {path: ':date',     component: ListNippoes, name: 'nippoes'},
        {path: ':date/new',  component: NewNippo},
        {path: ':date/edit', component: EditNippo},
        {path: '@:user_name/life/:date', component: Life}
      ]}
    ]}
  ]
});

開発中にちょっと気をつけたほうが良いなと思った点として、VueのMixinはあまり使い過ぎないことをおすすめします。複数のMixinをコンポーネントに入れていくと、どこで定義されたのかパッと分からないメソッドが増えていってしまいます。

テスト

ロジックの複雑なクラスに絞って、ランナーはKarmaフレームワークMochaアサーションChaiを使ってテストを書いています。コンポーネントのテストについては@hypermktさんによるブログエントリが詳しいです。E2Eテストは一切やっていません。E2Eテスト自体をメンテナンスするコストと、E2Eで得られるであろう恩恵を比べての判断です。

Go/Echo

ニポタマでは、家からも読み書きできて、かつ社外の人には日報を読めないようにするために、ユーザ認証にSlackのOAuthを使っています。OAuthにはstateパラメータというものがあり、これを使って、認証を要求した人と認証できたよと言っている人が本当に同じなのか、ということをチェックしています。ざっくりとした順序は

  • セッションにランダムな文字列を記録する
  • 同じ文字列をパラメータにつけてSlackの認証画面へ飛ばす
  • ユーザがSlackにログインする
  • codeと先ほどの文字列がstateパラメータに付いてリダイレクトされてくる
  • セッションにある文字列と比較して一致したらcodeを使ってaccess tokenを得る

という感じです。これをサボるとCSRFなどのリスクが生まれるのですが、開発初期はサボっていました。後になって対応しようとなったときに、APIサーバにこの役割を追加するか、別の言語で小さなサーバを書くか検討して、楽しそうな方を選びました。実装にはEchoというフレームワークとGorilla Toolkitのsessionsパッケージを使っています。役割上faceのCSSを使うことがあるので、faceと同じリポジトリに入れてしまっています。

役割

このサーバがどんな仕事をしているのか説明します。認証に関することをしているのでauthと呼んでいましたが実際よく見るとgatewayなどが適当かな、と思うので、gatewayサーバと呼んでいきます。まずリクエストを受けるnginxは、/logingatewayサーバへプロキシし、/ではfaceをserveするように設定してあります。/loginに来ると、gatewayがランダムな文字列をセッションとLogin with Slackボタンに埋め込みます。Slackログインしたユーザがリダイレクトされてくると、セッションに入れた文字列とstateパラメータを比較して、違っていたらエラーを表示します。両者が一致したら正当なログインなので、codeをbodyに送ります。bodyはSlackとやり取りし、以下の3通りの応答をしてきます。

  • 既にニポタマユーザとして登録済みである。ユーザ情報とJWTを与えよう
  • ニポタマにユーザ登録する資格がある。サインアップへ進むのだ
  • ニポタマにユーザ登録する資格がない。採用サイトへ飛ばせ

これらの内いずれかのレスポンスを受けたgatewayはfaceの適切なURLへリダイレクトします。faceはbodyとやり取りする中で認証に関するエラーを受けると/loginへリダイレクトし、認証やり直しとなります。なお、ここでのユーザ登録する資格があるか、とは、弊社Slack teamのメンバーとしてログインしてるか、などを見ています。

所感

ポン置きとはこのことか、というくらい、デプロイの簡単さに驚きでした。ビルドしてrsyncしておしまい。デプロイ後の起動はsystemdでやっています。

GOOS=linux GOARCH=amd64 gom build -o ./auth_server server.go
rsync -av -e ssh ./auth_server np-app:~/auth_server

Goで動くものを書いたのはほぼ初めてだったので、動いてるっぽいけどこんな書き方でいいのだろうか、という手探りな状態でした。あとで有識者に見てもらおうかと思います。

WebSocket

ニポタマをつくる上での哲学として、リアルタイム性に重きを置いており、誰かのアクションが他のユーザの画面にすぐに反映されるようにしています。具体的には、日報につけられたotsuや、日報が書かれている途中の様子は、画面を見ている他のユーザにWebSocketで配信されるようにしています。これは開発画面で2つウィンドウ開いて、片方で日報を書いているところです。日報が書かれている様子が見えています。まだ保存していない段階では、あえて誰が書いているのか見せないようにしているのですが、これが意外と楽しかったりします。

f:id:Joe_noh:20170222135729g:plain

実装としては、サーバ側はPhoenixChannel、クライアント側はそのクライアントを使っています。仮にクライアントの環境がWebSocketを使えない場合は勝手にLong Pollingにフォールバックしてくれます。簡単にコードを示すと、クライアントはtextareaのkeyupイベントで日報の内容が変化していたらnippo:updateイベントと共に日報の内容をサーバに送ります。それを受けたサーバは、送られてきた内容をその他のクライアントにbroadcastしています。接続はVueのmountedライフサイクルで行っています。

// クライアント
// socket.js
import config from '../config.js';
import * as Phoenix from 'phoenix';

let socket = new Phoenix.Socket(config.websocketUri);
socket.connect();

export default socket;
// クライアント
<template>
  <textarea v-model="content" @keyup="pushUpdate" required></textarea>
  ...
</template>

<script>
import socket from '../../lib/socket';

export default {
  ...
  mounted() {
    ...
    this.channel = socket.channel(`nippoes:${today}`, {token: token});
    this.channel.join();
  },
  methods: {
    ...
    pushUpdate: function() {
      if (this.content !== this.contentOld) {
        this.contentOld = this.content;
        this.channel.push('nippo:update', {content: this.content});
      }
    }
  }
}
</script>
# サーバ
defmodule NippoChannel do
  ...

  def join("nippoes:" <> _date, %{"token" => token}, socket) do
    case User.from_token(token) do
      {:ok, user} -> {:ok, assign(socket, :user_id, user.id)}
      _other      -> {:error, %{reason: "Unauthorized"}}
    end
  end

  def handle_in("nippo:update", %{"content" => content}, socket) do
    payload = %{
      user_id: socket.assigns.user_id,
      content: content
    }
    broadcast(socket, "nippo:update", payload)

    {:noreply, socket}
  end
end

所感

WebSocketを使うとサーバからのpushができるようになるので、表現の幅がグッと広がるなと感じています。アプリケーションとしてできることも増えますし、提供できる体験も変わってくると思います。ただし、ユーザの環境によっては接続が切れやすかったりするので、その点は留意したほうがよいでしょう。今後やりたいこととしては、Phoenix.Presenceで接続しているユーザの一覧が取れるので、何かおもしろい表現ができないかなと考えています。

Web Speech API

ニポタマには、投稿された日報を読み上げる機能があります。読み上げはブラウザのSpeechSynthesis APIを使っています。以下のようなmixinを用意しています。

export default {
  data() {
    return {
      synthes: null,
      speech: null,
    }
  },
  created: function() {
    if (typeof SpeechSynthesisUtterance !== 'undefined') {
      this.synthes = new SpeechSynthesisUtterance({lang: "ja-JP"});
      this.synthes.onend = () => {
        eventHub.$emit('set-is-playing', false);
      }
      this.speech = window.speechSynthesis;
    }
  },
  methods: {
    speakAll(date, nippoes) {
      if (this.synthes !== null) {
        eventHub.$emit('set-is-playing', true);
        let text =  date + 'の日報が' + nippoes.length + '件あります。';
        text += nippoes.map(d => this.breathingString() + `${d.user.nickname}さんの日報です。${d.readable_content}`).join('。');
        text += this.breathingString() + 'みなさんおつかれさまでした。';

        this.synthes.text = text;

        if (this.speech.paused) {
          this.speech.resume(this.synthes);
        } else {
          this.speech.speak(this.synthes);
        }
      }
    },
    // 0.5秒くらい間を開けます
    breathingString() {
      return "。\n\n\n\n\n\n";
    },
    stop() {
      if (this.synthes !== null) {
        eventHub.$emit('set-is-playing', false);
        this.speech.pause();
      }
    }
  }
}

if (typeof SpeechSynthesisUtterance !== 'undefined') {
  this.synthes = new SpeechSynthesisUtterance({lang: "ja-JP"});

  let text = `${name}さんの日報です。${body}`;

  this.synthes.text = text;
  speechSynthesis.speak(this.synthes);
}

SpeechSynthesis APIは漢字もちゃんと読んでくれるのですが、弊社のサービス名や略語など、例えばminne(ミンネ)やMTG(ミーティング)は正しく読めないので、日報を保存する前に@mickey19821さんが作ったRailsアプリに本文を送り、ちょっとだけ読みやすく置換したバージョンも保存して、読み上げにはこちらを使うようにしています。

所感

音声合成を手軽に使えるのはありがたいのですが、いささかWebサービスの中での使い所が難しいなと思います。また上のコードを見るとわかるように、句点や改行を挟んだり涙ぐましい細かい調整をしています。ただ色々やってもそれほど聴きやすい音声とは言えない印象です。十分すごいテクノロジーだとは思いますが、自然でなめらかな音声を期待するとアレっと感じると思います。実は開発初期はこの読み上げ機能をニポタマの目玉としていたのですが、いざ作ってみると開発陣としてもやや前衛的すぎるなと感じています。

PostCSS

ニポタマのデザインとマークアップ@shikakunさんが担当しており、CSSPostCSSを使って書かれています。利用しているプラグインは以下の通りです。

// .postcssrc.json
{
  "use": [
    "autoprefixer",
    "postcss-apply",
    "postcss-csso",
    "postcss-custom-media",
    "postcss-custom-properties",
    "postcss-hexrgba",
    "postcss-import",
    "postcss-mixins",
    "postcss-nested"
  ],
  "input": "src/css/style.css",
  "output": "public/assets/style.css",
  "autoprefixer": {
    "browsers": "last 2 versions"
  }
}

所感

CSSにはあまり明るくないので、Sassなどよりどのくらい書きやすいのかわかりませんが、こういう将来の仕様を先取りできるのはレガシー化を防ぐ上で重要かなと思っています。いつかはこのコードも古くなりますが、少なくとも今書き始めるならこうなのでは、という印象です。(書いてないくせに偉そうなこと言ってる。)

Open API

JSON APIの仕様はOpen API 2.0に則ってyamlに書いており、そのyamlファイルをbootprintbootprint-swaggerに与えて、APIドキュメントを生成しています。bodyと同じリポジトリの中で管理していて、npm run doc:deployを叩くと、手元でビルドしてその成果物がrsyncでデプロイされるようにしています。ドキュメントはBASIC認証だけかけて、どこからでも見れるようにしています。

所感

最初にえいやっと仕様だけ書いてしまえばドキュメント生成はライブラリに丸投げできるので、導入は楽だなと感じています。ただ、がっつりカスタマイズしたいようなときはそれなりに茨の道です。APIドキュメントについては最近考える機会があり、もうちょっとイケてる構成ができないかなぁと思っているので、この辺はまた後で何か書くと思います。

インフラ

ニポタマは、弊社のOpenStack環境Nyahの上で動いています。サーバの構成は

の3台です。ロールとしてはもっとあるのですが、アプリケーションサーバという名の1台にリバースプロキシ(nginx)、body、Goのgatewayが動いており、faceとAPIドキュメントの静的ファイルも置いてしまっています。apidocなど細かくサブドメインを切っていますが、バーチャルホストを設定して全てこの1台で受けています。構成管理はitamaeSSLLet’s Encryptにお世話になっています。DBのバックアップは主にオペミス対策として1日に1回取っていて、過去14日分だけ保持しています。幸いまだ役に立ったことはありません。

所感

元々はherokuからスタートして、その後クラウド便利だな〜くらいの感覚でNyahに普通な構成で移行してしまったのですが、時は2017年なので、例えばDockerで動かしたりするのもいいのかなと思っています。Elixir、node.js、Go、Rubyなどいろんな言語を使うようになってきている(次はPHPが入る予定)ので、本番環境よりむしろ開発環境でDockerを使うと何かと楽になりそうです。

まとめ

ざーっと書いてきましたが、改めていろいろと手を出してきたなという感じです。とはいえ実サービスではこんなにホイホイぶち込むこともできないので、これからも砂場として興味のあるものを試しながら運用していきたいなと思います。おつかれさまでした。

テーマはFB matteをベースにしてます。作者さんに感謝を込めて。