初心者がElixir Phoenix + MySQL でちゃんと公開できるJSON REST APIサーバーを作成するまで (Sana API Server)

  • 112
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

WEB+DB PRESS vol88&vol89のElixirの紹介が言語紹介の文章でいまだかつてないほどのわかり易さだったので
Elixirド素人がElixirとPhoenixフレームワークを使ってちゃんと公開できるREST APIサーバーを作ってみるまでを紹介します。
これを読めばたぶん2時間ぐらいで任意のデータのREST APIサーバーが作成できます。

フル版のソース - github

実際に作成したAPIの仕様
放映中のアニメ公式 Twitterアカウントのフォロワー変動履歴情報を提供するRESTful API サーバーを作りました

作成テーマ要件

  • Elixir PhoenixフレームワークでREST API サーバーを作る
  • 今回扱うデータは特定分野のTwitterアカウントリストのフォロワー履歴とする
  • MySQLに格納されたデータをREST API経由で取得できるようにする
  • インターネットにサーバーと仕様を公開するためセキュリティに注意する

詳細仕様

  • CRUDのRのみを提供(更新はしない、テーブル更新はバッチでシステムが行う想定)
  • 最終的にNginxと連携し、フロントの入り口はNginxにする
  • Railsからのシステムマイグレーションを想定し、テーブルはRailsで作られた想定で作成する

テーブル構成

SanaAPI DB.png

basesテーブルのidで各テーブルを紐付けます

テーブル 取り扱うデータ
bases アニメ作品のマスターデータ。作品名やTwitterアカウント
twitter_statuses Twitterアカウントの現在のフォロワー数
twitter_status_histories Twitterアカウントのフォロワー数の履歴

詳細なDDLはこちら shangrila-DDL

API仕様

エンドポイント 内容
/anime/v1/twitter/follower/status twitter_statusesの情報をJSONで返す
/anime/v1/twitter/follower/history twitter_status_historiesの情報をJSON返す

簡単ですね
1テーブルに1エンドポイントが紐づく形にします

Elixirのインストール

OSX

homebrew install elixir

Linux

https://www.erlang-solutions.com/downloads/download-erlang-otp
上記のPRMからErlangを入れてElixirソースコードをコンパイルします

git clone https://github.com/elixir-lang/elixir.git
cd elixir/
git checkout v1.1.1
make clean test
export PATH="$PATH:/path/to/elixir/bin"

Linuxの場合はdnfコマンドで入れる方法もあるようです。
Elixirが入るとライブラリ依存ツールのmixコマンドも使えるようになります。(rubyのbundler相当のコマンド)

エディタ

Vim

https://github.com/elixir-lang/vim-elixir

スクリーンショット 2015-11-15 1.55.12.png

その他、githubのElixirプロジェクトページにはEmacsとSublime Textのプラグインがあります。

Atom

スクリーンショット 2015-11-15 1.55.23.png

https://atom.io/packages/language-elixir

プロジェクトの作成

mixコマンドで作成

mix phoenix.new sana_server_phoenix --database mysql --no-brunch
cd sana_server_phoenix
mix phoenix.server

PhoenixフレームワークはデフォルトのデータベースをPostgreSQLにしているため
MySQL用にプロジェクトを作成する場合はオプション指定が必要です。

localhost:4000にアクセスすると起動が確認できます

スクリーンショット 2015-11-14 17.44.40.png

mix phoenix.serverでサーバーを起動します。
コードの変更は自動的に反映されるのでコードを更新して反映する必要はないです(ruby rerun相当の機構が最初から入っている)
ただし、設定ファイル系の更新は反映されないので、手動更新が必要です。

phoenixのmix コメントタスク一覧は以下
http://www.phoenixframework.org/docs/mix-tasks

依存するライブラリを指定

ログファイルを扱うためlogger_file_backendと日時のフォーマットを指定するためtimexを追加します

mix.exsはrubyでいうところのGemfileです

mix.exs
  defp deps do
    [{:phoenix, "~> 1.0.3"},
     {:phoenix_ecto, "~> 1.1"},
     {:mariaex, ">= 0.0.0"},
     {:phoenix_html, "~> 2.1"},
     {:phoenix_live_reload, "~> 1.0", only: :dev},
     {:cowboy, "~> 1.0"},
     {:timex, "~> 0.9"},
     {:logger_file_backend, "~> 0.0.5"}]
  end

欲しいモジュールはHexから探します。但しまだ数は少ない、発展途上。
Hexはモジュールを集めたサーバーでRubygemsのようなサイト。

依存するライブラリをダウンロード

mix deps.get

データベース接続設定

MySQLサーバーへの接続設定を記入します

config/dev.exs
# Configure your database
config :sana_server_phoenix, SanaServerPhoenix.Repo,
  adapter: Ecto.Adapters.MySQL,
  username: "root",
  password: "",
  database: "anime_admin_development",
  hostname: "localhost",
  pool_size: 10

config/prod.exsやconfig/test.exsも必要に応じて設定します

ルーティングの設定

web/router.ex に 以下を追加します

web/router.ex

  scope "/", SanaServerPhoenix do
    pipe_through :api
    resources "/anime/v1/twitter/follower/status", TwitterFollowerStatusController, only: [:index]
    resources "/anime/v1/twitter/follower/history", TwitterFollowerHistoryController, only: [:index]
  end

注意しないといけないのは[:index]を追加する事です。
指定しない場合CRUDの全てのルーティングが有効化されるので一般公開するサーバーは危険です

コントローラーを実装した後に以下のコマンドで現在のルーティングは確認できます

sana_server_phoenix (master)$ mix phoenix.routes
                    page_path  GET  /                                   SanaServerPhoenix.PageController :index
 twitter_follower_status_path  GET  /anime/v1/twitter/follower/status   SanaServerPhoenix.TwitterFollowerStatusController :index
twitter_follower_history_path  GET  /anime/v1/twitter/follower/history  SanaServerPhoenix.TwitterFollowerHistoryController :index

コントローラーの実装

twitter_follower_status_controller.ex

web/controllers/twitter_follower_status_controller.ex
defmodule SanaServerPhoenix.TwitterFollowerStatusController do
  use SanaServerPhoenix.Web, :controller

  def index(conn, _params) do

    #GETパラメーターを取得
    account_list = String.split(_params["accounts"],",")

    #IN句のプリペアードステートメントを作成 引数が5だったら [?, ?, ?, ?, ?] 
    prepared_statement_list = []
    prepared_statement_list = Enum.map(account_list, fn(x) ->
      prepared_statement_list = prepared_statement_list ++ ["?"]
    end)

    prepared_statement_in =  Enum.join(prepared_statement_list, ",")

    {:ok, twitter_status } = Ecto.Adapters.SQL.query(Repo,
      "SELECT b.twitter_account, follower, updated_at
      from twitter_statuses, (
       SELECT id, twitter_account FROM bases where twitter_account IN (#{prepared_statement_in})
      ) b
      where twitter_statuses.bases_id = b.id order by updated_at desc", account_list)

    dict = HashDict.new
    dict = List.foldr(twitter_status[:rows], dict,
    fn (x, acc) ->
      [twitter_account, follower, updated_at] = x
      check = HashDict.put(acc, twitter_account, %{:follower => follower, :updated_at => UnixTime.convert_date_to_unixtime(updated_at)})
    end)

    #変数dump
    #IO.inspect dict

    render conn, msg: dict
  end

end


コードを解説する前にコードを理解しやすいように実行してみます

実行例

curl http://localhost:4000/anime/v1/twitter/follower/status?accounts=usagi_anime,kinmosa_anime | jq .

accountsパラメーターにTwitterアカウントをカンマ区切りで渡します

実行結果

{
  "usagi_anime": {
    "updated_at": 1411466007,
    "follower": 51345
  },
  "kinmosa_anime": {
    "updated_at": 1432364961,
    "follower": 57350
  }
}

コード解説

  account_list = String.split(_params["accounts"],",")

GETパラメーターの取得

_params["accounts"]でGETパラメーターを取得します。
ここではカンマ区切りにして配列を生成しています。

Ectoで直接 生SQLを実行する

EctoはPhoenixに用意されているO/Rマッパー
Ecto.Adapters.SQL.queryで直接SQLを実行できる。

{:ok, SQLの結果 } = Ecto.Adapters.SQL.query(Repo, "SQLクエリ", bindパラメーターの配列)

ちなみにEctoのマニュアルにはバインドパラメーターを使いたい場合の文章サンプルが

Ecto.Adapters.SQL.query(Repo, "select * from from table where id = $1 and name = $2;", [param1, param2])

みたいになって混乱するが$でなく普通に?をつかってバインドパラメーターを指定する

Ecto.Adapters.SQL.query(Repo, "select * from from table where id = ? and name = ?;", [param1, param2])

SQLの結果を加工する

SQLの結果は以下のような配列に格納されます

SanaDB2.png

この配列をtwitter_accountをキーとしたハッシュ構造に変換します

{
  "usagi_anime": {
    "updated_at": 1411466007,
    "follower": 51345
  },
  "kinmosa_anime": {
    "updated_at": 1432364961,
    "follower": 57350
  }
}
    dict = HashDict.new
    dict = List.foldr(twitter_status[:rows], dict,
    fn (x, acc) ->
      [twitter_account, follower, updated_at] = x
      check = HashDict.put(acc, twitter_account, %{:follower => follower, :updated_at => UnixTime.convert_date_to_unixtime(updated_at)})
    end)
  • HashDict.newでハッシュを作成
  • twitter_status[:rows]でSQLの結果を格納。twitter_statusにはSQL構文など結果以外の情報もが格納されている
  • List.foldr(twitter_status[:rows], dict, ・・第一引数にSQL結果配列、第二引数に格納対象のハッシュを指定
 [twitter_account, follower, updated_at] = x

これはElixirの構文で右辺の式を左辺の変数に分解する

ハッシュ構造にデータを追加
HashDict.put(格納先のハッシュ, (追加する)ハッシュのキー, (追加する)ハッシュの値)
 HashDict.put(acc, twitter_account, %{:follower => follower, :updated_at => UnixTime.convert_date_to_unixtime(updated_at)})

このコードでハッシュにデータが追加される。
%{:A => B} はハッシュを表す。

Elixirの変数は不変

dict = List.foldr(twitter_status[:rows], dict,

dictに値を返しているのは、Elixirの変数が不変なためHashDict.putでハッシュを追加してもオリジナルの変数は値が変更されません。
ここらへんはElixirのIO.inspectを用いて変数のダンプを追いかけると理解しやすいでしょう。

twitter_follower_history_controller.ex

web/controllers/twitter_follower_history_controller.ex
defmodule SanaServerPhoenix.TwitterFollowerHistoryController do
  use SanaServerPhoenix.Web, :controller
  require Logger

  def index(conn, _params) do
    response = []

    #IO.inspect Logger.metadata()

    account = _params["account"]
    param_end_date = _params["end_date"]

    #ElixirではIFをパターンマッチで行う
    end_date = try do
      case param_end_date do
        nil -> DateUtil.now_format
        _ -> UnixTime.convert_unixtime_to_date(String.to_integer(param_end_date))
      end
    rescue
      e in ArgumentError -> e
      #Exception.messageで例外メッセージを取得
      Logger.warn "error param end_date. " <> Exception.message(e)
      DateUtil.now_format
    end

    {:ok, twitter_status } = Ecto.Adapters.SQL.query(Repo,
      "SELECT h.follower, h.updated_at
      from twitter_status_histories as h,
      (SELECT id FROM bases WHERE twitter_account = ? order by id desc limit 1) b
       where h.bases_id = b.id AND h.updated_at < ? order by h.updated_at desc limit ?",
       [account, end_date, 100])

    response = List.foldr(twitter_status[:rows], response,
    fn (x, acc) ->
      [follower, updated_at] = x
      response = [%{:follower => follower, :updated_at => UnixTime.convert_date_to_unixtime(updated_at)}] ++ acc
    end)

    render conn, msg: response
  end

end

#http://michal.muskala.eu/2015/07/30/unix-timestamps-in-elixir.html
defmodule UnixTime do
  #JSTなので9Hにしておく
  epoch = {{1970, 1, 1}, {9, 0, 0}}
  @epoch :calendar.datetime_to_gregorian_seconds(epoch)

  def convert_date_to_unixtime(created_at) do
    {{year, month, day}, {hour, minute, second, msec}} = created_at
    gs = :calendar.datetime_to_gregorian_seconds({{year, month, day}, {hour, minute, second}})
    gs - @epoch
  end

  def convert_unixtime_to_date(timestamp) do
    {:ok, date_string} =
    timestamp
      |> +(@epoch)
      |> :calendar.gregorian_seconds_to_datetime
      |> Timex.Date.from
      |> Timex.DateFormat.format("%Y-%m-%d %H:%M:%S", :strftime)
    date_string
  end

end

defmodule DateUtil do
  def now_format() do
    {:ok, date_string} =
      :erlang.localtime
      |> Timex.Date.from
      |> Timex.DateFormat.format("%Y-%m-%d %H:%M:%S", :strftime)
    date_string
  end
end

viewの実装

コントローラーに対応するViewファイルを2つ用意します。
今回はコントローラーでデータ加工されたデータをJSONにして返すだけなので中身はほとんどありません。

web/views/twitter_follower_status_view.ex
defmodule SanaServerPhoenix.TwitterFollowerStatusView do
  use SanaServerPhoenix.Web, :view

  def render("index.json", %{msg: msg}) do
   msg
  end

end

web/views/twitter_follower_history_view.ex
defmodule SanaServerPhoenix.TwitterFollowerHistoryView do
  use SanaServerPhoenix.Web, :view

  def render("index.json", %{msg: msg}) do
   msg
  end

end

その他 アドバンス

プロダクション起動

MIX_ENV=prod mix compile.protocols
MIX_ENV=prod PORT=4000 mix phoenix.server

Nginxとの連携

UNIXソケットでなく普通にHTTPプロキシします


server {
      listen       80;
      server_name api.moemoe.tokyo;
      location /anime/v1/twitter/ {
         proxy_pass http://localhost:4000;
      }
    }

ログファイルの設定

このままだとコンソールにログが流れっぱなしになるのでconfigに設定を追加します

config/config.exs
# Configures Elixir's Logger
config :logger, backends: [{LoggerFileBackend, :file}]
config :logger, :file,
  path: "./log/sana_api_phoenix.log",
  format: "$time $metadata[$level] $message\n",
  metadata: [:request_id]
tail -f ./log/sana_api_phoenix.log


17:44:37.251 request_id=cgse5i65gge1u70cdf8flfsjbauo52d4 [info] Sent 200 in 90ms
17:44:37.611 request_id=k43krmuoii9pi3vmvqjskdm22fnucpqf [info] GET /apple-touch-icon-precomposed.png
17:44:37.692 request_id=i15kbm8qhjueesqeclro53mcm5jga2gl [info] GET /apple-touch-icon.png
18:19:38.998 request_id=k11tmlh81emuadp1cah2e26aenfrpm72 [info] GET /anime/v1/twitter/follower/status
18:19:38.999 request_id=k11tmlh81emuadp1cah2e26aenfrpm72 [debug] Processing by SanaServerPhoenix.TwitterFollowerStatusController.index/2
  Parameters: %{"accounts" => "usagi_anime,kinmosa_anime"}
  Pipelines: [:api]
18:19:39.078 request_id=k11tmlh81emuadp1cah2e26aenfrpm72 [debug] SELECT b.twitter_account, follower, updated_at
      from twitter_statuses, (
       SELECT id, twitter_account FROM bases where twitter_account IN (?,?)
      ) b
      where twitter_statuses.bases_id = b.id order by updated_at desc ["usagi_anime", "kinmosa_anime"] OK query=8.5ms queue=60.0ms
18:19:39.087 request_id=k11tmlh81emuadp1cah2e26aenfrpm72 [info] Sent 200 in 88ms

環境に関係なくログを残したいならconfig.exsを編集。
「devはコンソールログで、prodはログファイルにしたい」など環境ごとに分けたい場合はそれぞれの環境ファイルに記述を追加してください。

まとめ

RubyとElixirの比較表

Ruby Elixir 役割
bundler mix プロジェクト管理ツール、ライブラリ依存ツール
Gemfile mix.exs プロジェクトで必要なライブラリを記述
bundle install mix deps.get ライブラリ ダウンロード
Rubygems Hex ライブラリを管理する中央サーバー
Rails Phoenix フルスタック WEBフレームワーク
rerun Phoenixに標準搭載 プログラムの更新を自動的に反映
Active Record Ecto O/Rマッパー
p IO.inspect 変数のダンプ出力

今回はMySQLを使いましたが、Elixir(BEAM VM)のパフォーマンス特性を活かすにはDBがボトルネックになるのでmemcachedを入れたりインメモリDBに置き換えるなどの対策がそのうち必要でしょう。

RailsやRailsライクフレームワークを触ったことがある人は、Elixirという言語の文法をある程度理解できれば、難なくPhoenixでサーバーが構築できます。
コーディングの難易度もErlangに比べたら高くなく、スムーズにRailsから以降できるのでリクエストが多すぎて捌き切れない事が想定されるAPIサーバーは、Elixir Phoenixで作成することも検討に入れてもいいと思います。

この投稿は Elixir Advent Calendar 201525日目の記事です。