Elixir
Phoenix
ElixirDay 25

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

More than 3 years have passed since last update.

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で作成することも検討に入れてもいいと思います。