WEB+DB PRESS vol88&vol89のElixirの紹介が言語紹介の文章でいまだかつてないほどのわかり易さだったので
Elixirド素人がElixirとPhoenixフレームワークを使ってちゃんと公開できるREST APIサーバーを作ってみるまでを紹介します。
これを読めばたぶん2時間ぐらいで任意のデータのREST APIサーバーが作成できます。
実際に作成したAPIの仕様
放映中のアニメ公式 Twitterアカウントのフォロワー変動履歴情報を提供するRESTful API サーバーを作りました
作成テーマ要件
- Elixir PhoenixフレームワークでREST API サーバーを作る
- 今回扱うデータは特定分野のTwitterアカウントリストのフォロワー履歴とする
- MySQLに格納されたデータをREST API経由で取得できるようにする
- インターネットにサーバーと仕様を公開するためセキュリティに注意する
詳細仕様
- CRUDのRのみを提供(更新はしない、テーブル更新はバッチでシステムが行う想定)
- 最終的にNginxと連携し、フロントの入り口はNginxにする
- Railsからのシステムマイグレーションを想定し、テーブルはRailsで作られた想定で作成する
テーブル構成
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
その他、githubのElixirプロジェクトページにはEmacsとSublime Textのプラグインがあります。
Atom
プロジェクトの作成
mixコマンドで作成
mix phoenix.new sana_server_phoenix --database mysql --no-brunch
cd sana_server_phoenix
mix phoenix.server
PhoenixフレームワークはデフォルトのデータベースをPostgreSQLにしているため
MySQL用にプロジェクトを作成する場合はオプション指定が必要です。
localhost:4000にアクセスすると起動が確認できます
mix phoenix.serverでサーバーを起動します。
コードの変更は自動的に反映されるのでコードを更新して反映する必要はないです(ruby rerun相当の機構が最初から入っている)
ただし、設定ファイル系の更新は反映されないので、手動更新が必要です。
phoenixのmix コメントタスク一覧は以下
http://www.phoenixframework.org/docs/mix-tasks
依存するライブラリを指定
ログファイルを扱うためlogger_file_backendと日時のフォーマットを指定するためtimexを追加します
mix.exsはrubyでいうところのGemfileです
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サーバーへの接続設定を記入します
# 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 に 以下を追加します
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
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の結果は以下のような配列に格納されます
この配列を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
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にして返すだけなので中身はほとんどありません。
defmodule SanaServerPhoenix.TwitterFollowerStatusView do
use SanaServerPhoenix.Web, :view
def render("index.json", %{msg: msg}) do
msg
end
end
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に設定を追加します
# 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で作成することも検討に入れてもいいと思います。