ルモーリン
ホーム 更新 Perl Sample ランドナー サービス 雑談 コースガイド 鉄ゲタ 自転車 Linux リンク 連絡先

アクセスカウンターを設置

2019-08-13

背景

ホームページの基本、アクセスカウンターがないことに気付いたので設置しました。 …嘘です。 ホントはこちらのツイートから。 目指せ、年収400万!(笑)

第一段階:昔風カウンター

昔のアクセスカウンターはトップページに設置して、クライアントからのリクエストを受けてカウントします。 他のページへいきなりジャンプするとアクセスカウンターがリクエストを把握できずカウントできません。 そういう背景があるのか直リン(他のページに直接リンクする行為)が禁止になっているサイトをよく見かけました。 こんな解説をしていると「#インターネット老人会 - Twitter検索 / Twitter」みたいだ(老眼)。

カウント処理

当サイトはトップページ用のコントローラーを書いてあります。 クライアントがトップページを表示した際に呼ばれるので、そこにカウント処理を入れます。 リロードでのカウントアップ抑止として1時間有効のクッキーがあればカウントアップしません。 それとカウント結果をスタッシュに入れてトップページのテンプレートへ渡します。 昔のApacheとかのWebサーバーですと、クライアントからのリクエストに合わせてCGIが呼ばれてしまいます。 それは非同期ですから競合するもの(カウント値)は調整しないといけませんでした。 でも当サイトのMojoliciousは単一プロセスで完結しているので、複数クライアントからのリクエストであっても競合しません。

# アクセスカウンター
my $file = Mojo::File->new("access_counter.txt");
$file->touch; # ファイルが無ければ空ファイルを作る
my $count = $file->slurp;
$count //= 0; # データが無ければ0にする
my $check = $self->session("already_access");
# クッキーが無ければカウントアップ
if (!defined $check) {
	$count++;
	$file->spurt($count);
	# クッキーを設定
	$self->session({already_access => 1});
	# 1時間有効(デフォルト)
	# $self->session(expiration => 3600);
}

# テンプレートに渡す
$self->stash(access_counter => $count);

表示処理

トップページのテンプレートでカウンタの文言とスタッシュを参照します。 一見すると昔なつかしSSI(サーバーサイドインクルード)のように見えますけれど、そこは我らがMojoliciousです。 なのでテンプレートからページを生成する際にスタッシュを参照してカウント結果に置き換わります。

<p>
あなたは<%== $access_counter %>人目のお客様です。<br />
2018-01-06 現在で455ページあります。
</p>

第二段階:今風アクセスカウンター

時は流れて2019年、トップページでクライアントからのリクエストのみでカウントする時代は終わりました。 WebサーバーがMojoliciousならルーター(応答ページの処理順の設定モジュール)の設定を調整すれば、どのページにアクセスしても必ずカウントできます。

プラグイン

アクセスカウンターのプラグインを作ります。 プラグインが登録される際にヘルパー関数を追加します。 2つのヘルパー関数(PpAccessCounter_get、PpAccessCounter_count)はコントローラーからいつでも呼び出せます。

package PpAccessCounter;
use Mojo::Base "Mojolicious::Plugin";

sub register {
	my ($self, $app, $conf) = @_;

	$app->helper(PpAccessCounter_get => sub {
		my $self = shift;

		my $file = Mojo::File->new("access_counter.txt");
		$file->touch; # ファイルが無ければ空ファイルを作る
		my $count = $file->slurp;
		$count //= 0; # データが無ければ0にする
		return $count;
	});

	$app->helper(PpAccessCounter_count => sub {
		my $self = shift;

		# クッキーが無ければカウントアップ
		my $check = $self->session("already_access");
		if (!defined $check) {
			Mojo::File->new("access_counter.txt")->spurt(1 + $self->PpAccessCounter_get);
			
			# クッキーを設定
			$self->session({already_access => 1});

			# 1時間有効(デフォルト)
			# $self->session(expiration => 3600);
		}
	});
}

1;

カウントアップのタイミング

サイトのどこにアクセス(クライアントからのリクエスト)があってもカウントするよう工夫します。 アプリケーションのstartupでルーターを設定する冒頭に次のコードを入れます。 リクエストのURLでドメイン名の次が「/」で始まる場合に(つまり全部)コールバックを登録してヘルパー関数のカウント処理を呼び、1を返してルーター処理を継続させます。 サブルーチンの中はコントローラーなので$selfで良いのですが、startupの$selfと混同しそうなので$cにしています。 このコードに続いてルーターの設定があるので$rを書き替えています。

# アクセスカウンター
$r = $r->under("/" => sub {
	my $c = shift;

	$c->PpAccessCounter_count;
	return 1;
});

表示処理

テンプレートでの表示は第一段階とほぼ同じですがトップページのリクエスト時に行われたスタッシュの追加がありません。 代わりにヘルパー関数を呼び出します。

<p>
あなたは<%== PpAccessCounter_get() %>人目のお客様です。<br />
2018-01-06 現在で455ページあります。
</p>

第三段階:hypnotoad対応

2019-08-06 複数プロセスに対応しました

いつかはhypnotoad(ハイプノトードと呼ぶらしい)… つまり、複数プロセスで並行処理できるアクセスカウンターにしたい。 第二段階までは単一プロセスだから競合の心配なく簡単な処理でした。 でも、並行処理が前提になると、1個のリソースを奪い合うので調停が面倒くさいです。

既存の処理

例えばMojoliciousのログ出力は1個のリソースだから、この処理が参考になりそうと見てみると普通にflock掛けてた。 確かにflockで済むけれど、なんかこう、つまらない。
「Perl の移植性のある ファイルロックインターフェースです」Perlの組み込み関数 flock の翻訳 - perldoc.jp

redis

何かないかなーと探してredisというのを見つけました。→Mojolicious+redisでチャットを作った
rootで「yum install redis」してから一般ユーザーで「redis-server」(redisのサーバーを常駐)させておき、Mojoliciousのコントローラーからredisのクライアントを実行してredisへ連絡できます。 都合の良いことに単一の値を数値とみなしてインクリメント(+1)する機能があり、そのままアクセスカウンターに使えます。

redisサーバーの常駐

コマンドラインでredis-serverを起動すれば常駐しますけれど、sshで普通に起動するとログアウトで終了してしまいます。 そこでバックグラウンドで実行、ログアウトでも常駐できるようなスクリプト「redis_start.sh」を作りました。

#!/bin/bash

THIS_FILE_PATH=`readlink -e $0`
FILE_DIRECTORY=${THIS_FILE_PATH%/*}
THIS_FILE=${THIS_FILE_PATH##*/}
# SCRIPT_FILE=${THIS_FILE/%.sh/.pl}
ERROR_LOG_FILE=${THIS_FILE%.sh}_error.log

cd ${FILE_DIRECTORY}
nohup redis-server </dev/null >/dev/null 2>>${ERROR_LOG_FILE} &

sleep 3
pgrep -fa redis-server

プラグインの修正

プラグインの中でredisのクライアントを実行します。 カウントアップと表示の処理は変更ありません。

package PpAccessCounter;
use Mojo::Base "Mojolicious::Plugin";

use Mojo::Redis;

use constant COUNT_KEY => "access_counter";
use constant COOKIE_NAME => "already_access";

sub register {
        my ($self, $app, $conf) = @_;

        $app->helper(PpAccessCounter_get => sub {
                my $self = shift;

                my $count = Mojo::Redis->new->db->get(COUNT_KEY);
                return $count;
        });

        $app->helper(PpAccessCounter_count => sub {
                my $self = shift;

                # クッキーが無ければカウントアップ
                my $check = $self->session(COOKIE_NAME);
                if (!defined $check) {
                        # クッキーを設定
                        $self->session({COOKIE_NAME() => 1});
                        # $self->session(expiration => 3600); # デフォルトは1時間有効

                        # Redisがインクリメント
                        Mojo::Redis->new->db->incr(COUNT_KEY);
                }
        });
}

1;

カウント値の調整

カウント値の引き継ぎや修正は「redis-cli」コマンドでクライアントを実行してできます。

第四段階:redisサーバー停止対応

2019-08-13 第四段階を追加しました。

redisはサーバー(redis-server)を起動しておくのが前提です。 Mojoliciousと同様に一般ユーザーで稼働させている都合があり、Mojoliciousが稼働していてもredisが停止しているケースがあります。 試しにredisを止めるとMojoliciousからMojo::redisを使った途端にdieしてメッセージが出ます。 ウチのMojoliciousはシングルプロセスで稼働させていますから、dieするのは以ての外です。 redisサーバーの生死判定を加えてdieを回避しましょう。

死んでるサーバーにpingはdie

Mojo::redisを追いかけるとpingがありサーバーにpingするとpongが返るというので、死んだサーバーにpingすると途端にdieします。 何のためのpingなんだろう…
「ping」Mojo::Redis::Database - Execute basic redis commands - metacpan.org

getやincrのpromise版

promise版は完了コールバックを指定し、呼び出し直後に戻る形式でエラー時もdieしないでコールバックされます。 エラー時にdieしないのは良いのですけれど、呼び出し直後に戻ってきますから、普通に書くと応答を得る前に制御が戻ってしまいます。

redisの呼び出しをタイマーで独立

クライアントからのリクエストに合わせてredisを呼んでも別の契機に応答されますから、 リクエストと独立させてタイマー駆動にします。 プラグインが登録された際に読み出しイベントとインクリメントイベントの2つのタイマーを起動、3秒毎に繰り返し実行されます。 リクエストに応じてカウント値の参照やインクリメント用の変数を操作しますが、このスクリプト自体は単一プロセスなので競合しません。 hypnotoad稼働でもプロセス毎に独立、redisを呼ぶ所までは競合せず、redisのサーバーで調停されます。

package PpAccessCounter;
use Mojo::Base "Mojolicious::Plugin";

use Mojo::Redis;

use constant COUNT_KEY => "access_counter";
use constant COOKIE_NAME => "already_access";

sub register {
	my ($self, $app, $conf) = @_;

	my $count = 0;
	Mojo::IOLoop->recurring(3 => sub {
		my $loop = shift;

		# 一度切断すると再接続しないので都度接続
		Mojo::Redis->new->db->get(COUNT_KEY, sub {
			my ($db, $err, $res) = @_;

			# $app->log->debug("count err: $err, res: $res");
			$count = $res if defined $res;
		});
	});

	$app->helper(PpAccessCounter_get => sub {
		my $self = shift;

		return $count;
	});

	my $increment = 0;
	Mojo::IOLoop->recurring(3 => sub {
		my $loop = shift;

		if ($increment) {
			# redisがインクリメント
			# 一度切断すると再接続しないので都度接続
			Mojo::Redis->new->db->incrby(COUNT_KEY, $increment, sub {
				my ($db, $err, $res) = @_;

				# $app->log->debug("increment err: $err, res: $res");
				# 上手く行ったら刈り取り
				$increment = 0 if !$err;
			});
		}
	});
	$app->helper(PpAccessCounter_count => sub {
		my $self = shift;

		# クッキーが無ければカウントアップ
		my $check = $self->session(COOKIE_NAME);
		if (!defined $check) {
			# クッキーを設定
			$self->session({COOKIE_NAME() => 1});
			# $self->session(expiration => 3600); # デフォルトは1時間有効

			# インクリメント後、タイマーが刈り取る
			$increment++;
			# $self->app->log->debug("PpAccessCounter_count $increment");
		}
	});
}

1;