Phoenix を使ってブラウザ上でログファイルをリアルタイムに tail するアプリケーションを作ってみます。
そう、つまりこれは、Phoenix Tail。
_人人人人人人人人人人_
> フェニックスの尾1 <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y ̄
完成イメージ
こんな感じです。
Phoenix アプリケーションをセットアップする
早速アプリケーションを作っていきましょう。
PhoenixTail という名前で作ります。
$ mix phoenix.new phoenix_tail
WebSocket の設定をしておきます(詳細はコチラ)
defmodule PhoenixTail.UserSocket do
use Phoenix.Socket
## Channels
# 以下の行を追加
channel "tails:*", PhoenixTail.TailChannel
## Transports
transport :websocket, Phoenix.Transports.WebSocket
...
end
TailChannel を作成します。今回はクライアントからの Push は想定していないので、join だけ実装します。
defmodule PhoenixTail.TailChannel do
use Phoenix.Channel
def join("tails:sample", auth_msg, socket) do
{:ok, socket}
end
def join("tails:" <> _private_room_id, _auth_msg, socket) do
{:error, %{reason: "unauthorized"}}
end
end
ログファイルを監視するプロセスを作る
次に、tail の対象とするログファイルを監視する部分を実装します。
処理の流れとしては、
- 定期的に対象のファイルをチェック
- 変更が入っていれば、前回取得行以降の行を取得
- 取得した内容を WebSocket に broadcast
という感じで、サーバへのリクエストなどとは一切関係ないものとなります。
つまり、独立したプロセスを作る必要があるのですが、これは Elixir がすこぶる得意とする分野で、GenServer という概念を使うとシンプルに実装できます。GenServer については、@naoya@github さんのコチラの記事が大変参考になります。
ソースコードの全容はこんな感じです。
defmodule PhoenixTail.Tail do
use GenServer
# 親プロセスとのリンク処理
def start_link(file) do
# 引数で受け取ったターゲットファイルのパスを渡します
GenServer.start_link(__MODULE__, {file})
end
# 初期化処理
def init({file}) do
# 受け取ったファイルパスをストリームにして、cast を呼びます
stream = File.stream!(file)
GenServer.cast(self, :check)
{:ok, {stream, nil, 0}}
end
def handle_cast(:check, {stream, last_modified, position}) do
# 変更をチェックして broadcast します
{last_modified, position} = cast_new_lines(stream, last_modified, position)
# 1秒スリープ後、処理を再帰します
:timer.sleep(1000)
GenServer.cast(self, :check)
{:noreply, {stream, last_modified, position}}
end
defp cast_new_lines(stream, last_modified, position) do
cond do
# ファイルが存在しない場合はスルー
!File.exists?(stream.path) ->
{last_modified, position}
# ファイルが前回チェック時より更新されていない場合もスルー
File.stat!(stream.path).mtime == last_modified ->
# カレントポジションはアップデートしておきます
{last_modified, length(Enum.into(stream, []))}
true ->
# パイプライン演算子を使って、カレントポジション以降の行をリスト化
lines = stream
|> Stream.drop(position)
|> Enum.into([])
if (length(lines) > 0) do
# "tails:sample" チャンネルに行のリストを broadcast
PhoenixTail.Endpoint.broadcast! "tails:sample", "new_lines", %{lines: lines}
end
# 最終更新日時とカレントポジションをアップデートしておきます
{File.stat!(stream.path).mtime, position + length(lines)}
end
end
end
GenServer が用意している callback 用の関数だけで実装できました。
GenServer 理解の1つのポイントとして、「状態を保持させる」ということがあります。この「状態」というのは、init/1
や handle_cast/2
で return している内容です。これが次回の関数コールに引き回されることで、状態の保持を実現しています。
プロセス指向独特の概念でちょっと分かりづらいですが...思考をシフトさせましょう。
プロセスを Supervisor に登録する
プロセスの実装が終わったら、それを Phoenix の Supervisor に登録してみましょう。
defmodule PhoenixTail do
use Application
# See http://elixir-lang.org/docs/stable/elixir/Application.html
# for more information on OTP Applications
def start(_type, _args) do
import Supervisor.Spec, warn: false
children = [
# Start the endpoint when the application starts
supervisor(PhoenixTail.Endpoint, []),
# Start the Ecto repository
worker(PhoenixTail.Repo, []),
# Here you could define other workers and supervisors as children
# worker(PhoenixTail.Worker, [arg1, arg2, arg3]),
# 以下の1行を追加
worker(PhoenixTail.Tail, ["data/sample.log"])
]
...
end
...
end
これで起動時にプロセスが立ち上がり、監視されるようになります。
引数で渡している data/sample.log
が tail のターゲットとなるのですが、このファイルについては後述します。
フロントエンドを作る
次に、サーバから broadcast されるログ情報をレンダリングする部分を作ります。デフォルトで生成されるページをちょろっと改造して済ませましょう。
まずは依存 JS ライブラリを追加します。
Brunch を使ってもいいのですが、手間なので CDN から直接読み込ませます。
...
<%= @inner %>
</div> <!-- /container -->
<script src="//code.jquery.com/jquery-2.1.4.min.js"></script>
<script src="//cdn.jsdelivr.net/jsrender/1.0pre35/jsrender.min.js"></script>
<script src="<%= static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>
次にコンテンツです。
既存コードを全消しして以下のものだけにして大丈夫です。
<div class="row">
<div class="col-xs-12">
<ul id="loglines" class="list-unstyled">
</ul>
</div>
</div>
<script id="tmpl-logline" type="text/x-jsrender">
<li class="logline">
{{>#data}}
</li>
</script>
JS はこんな感じです。
これも既存は全消しで OK です。
import {Socket} from "deps/phoenix/web/static/js/phoenix/"
var socket = new Socket("/socket");
socket.connect();
var channel = socket.channel("tails:sample", {});
channel.join();
var tmpl = $("#tmpl-logline"),
loglines = $("#loglines");
channel.on("new_lines", function(dt) {
var lines = tmpl.render(dt.lines);
$(lines).each(function(i, e) {
$(e).animate({ "background-color": "#fff" }, 1000);
});
loglines.prepend(lines);
});
最後に CSS です。
これは末尾に追加で。ブラウザ差分などは適宜調整してください...
...
.logline {
font-size: 8px;
margin: 5px 0;
padding: 5px 0;
border-radius: 4px;
border-bottom: 1px solid #ddd;
background-color: #fff;
animation-name: animation;
animation-duration: 1s;
animation-timing-function: ease-in-out;
animation-iteration-count: 1;
}
.logline:first-child {
font-size: 16px;
}
@-webkit-keyframes animation {
0% {background-color: #f5f5dc;}
100.0% {background-color: #ffffff;}
}
以上でフロントエンドは完了です。
ダミーのログファイルを作る
本当は実際に Web サービスが稼働しているサーバの apache ログを対象にしたかったのですが、手頃なものがなかったので、ダミーを生成します。
世の中便利なもので、apache ログを生成してくれるツールが存在しました。
これをインストールして、先ほど指定した data/sample.log
にツラツラとダミーを出力させておきましょう(相対パスなので実行するディレクトリにはご留意ください)
$ apache-loggen --rate=2 data/sample.log
動作を確認する
さて、以上で準備は整いました。
おもむろにアプリケーションを起動して、localhost:4000
にアクセスしてみましょう。
いい感じですね!
一定量のダミーが生成されているのでリアルタイム感が薄いですが、紛れもなく tail の挙動です。
感想
- ネタでやったつもりが、意外と GenServer の勉強になった
- 「開発時にログを見るならtailよりもless!!」というツッコミはNG
- ブラウザから tail するファイルを指定できたりすると素敵
- サーバをまたいでファイルを指定できるともっと素敵
-
残念ながら正確には Phoenix Down と訳すらしい。 ↩