スポンサーリンク

CakePHP3のアクセスを記録、集計するプラグインを作った

CakePHP3のプラグイン、ミドルウェア、コンソールコマンドのメモと、作ったプラグインについての備忘録です。

アクセスを記録、集計するプラグインについて

作ったもの↓
GitHub:https://github.com/imo-tikuwa/cakephp-operation-logs

CakePHP3フレームワークを経由しているすべてのリクエストについて、IPアドレス、ユーザーエージェント、リクエストURLの情報とリクエスト日時、リクエスト処理が完了した日時を記録しています。
テーブル定義は以下のような感じ。

CREATE TABLE IF NOT EXISTS `operation_logs` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `client_ip` text NOT NULL COMMENT 'クライアントIP',
  `user_agent` text DEFAULT NULL COMMENT 'ユーザーエージェント',
  `request_url` varchar(255) NOT NULL COMMENT 'リクエストURL',
  `request_time` datetime NOT NULL COMMENT 'リクエスト日時',
  `response_time` datetime NOT NULL COMMENT 'レスポンス日時',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='操作ログ';

できるだけ簡潔な導入が行えることを考慮した結果、ミドルウェアでの実装を行うことになりました。ミドルウェアの自作は行ったことがないのでその勉強も兼ねてる。

ミドルウェアについてはCakePHP3公式のドキュメントのタマネギを輪切りにしたみたいな画像イメージがわかりやすかった。
参考:ミドルウェア – 3.8

プラグインをbake→ミドルウェアをbake→モデルをbakeする

ミドルウェアとモデルクラスを作成するときに–pluginオプションで作成したプラグイン名を設定することでプラグインの配下にソースがbakeされるようになります。

cake bake plugin OperationLogs
cake bake middleware --plugin=OperationLogs OperationLogs
cake bake model operation_logs --plugin=OperationLogs
cake bake model operation_logs_hourly --plugin=OperationLogs
cake bake model operation_logs_daily --plugin=OperationLogs
cake bake model operation_logs_monthly --plugin=OperationLogs

アクセスの記録について

<?php
namespace OperationLogs\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Cake\ORM\TableRegistry;
use Cake\Routing\Router;
use Cake\I18n\Time;
use Cake\Core\InstanceConfigTrait;
use OperationLogs\Util\OperationLogsUtils;
use Cake\Datasource\ConnectionManager;

/**
 * OperationLogs middleware
 *
 * @property \OperationLogs\Model\Table\OperationLogsTable $OperationLogs
 *
 * @see https://book.cakephp.org/3/ja/controllers/middleware.html
 */
class OperationLogsMiddleware
{
  use InstanceConfigTrait;

  /**
   * default configs.
   * @var array
   */
  protected $_defaultConfig = [
      'exclude_urls' => [
          '/debug-kit',
      ],
  ];

  /**
   * constructer
   * @param array $config
   */
  public function __construct(array $config = [])
  {
    $this->setConfig($config);
  }

  /**
   * Invoke method.
   *
   * @param \Psr\Http\Message\ServerRequestInterface $request The request.
   * @param \Psr\Http\Message\ResponseInterface $response The response.
   * @param callable $next Callback to invoke the next middleware.
   * @return \Psr\Http\Message\ResponseInterface A response
   */
  public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next)
  {
    // リクエスト前処理

    // 除外設定
    $request_url = $request->getUri()->getPath();
    foreach ($this->getConfig('exclude_urls') as $exclude_url) {
      if (OperationLogsUtils::starts_with($request_url, $exclude_url)) {
        return $next($request, $response);
      }
    }

    $request_time = Time::now();

    $response = $next($request, $response);

    // リクエスト後処理
    $response_time = Time::now();

    $this->_create_table_if_not_exists();
    $this->OperationLogs = TableRegistry::getTableLocator()->get('operation_logs');
    $entity = $this->OperationLogs->newEntity([
        'client_ip' => Router::getRequest()->clientIp(),
        'user_agent' => @$request->getHeader('User-Agent')[0],
        'request_url' => $request_url,
        'request_time' => $request_time,
        'response_time' => $response_time,
    ]);
    $this->OperationLogs->save($entity);

    return $response;
  }

  /**
   * 操作ログテーブル作成
   */
  private function _create_table_if_not_exists() {
    $connection = ConnectionManager::get('default');
    $query = <<<EOL
CREATE TABLE IF NOT EXISTS `operation_logs` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `client_ip` text NOT NULL COMMENT 'クライアントIP',
  `user_agent` text DEFAULT NULL COMMENT 'ユーザーエージェント',
  `request_url` varchar(255) NOT NULL COMMENT 'リクエストURL',
  `request_time` datetime NOT NULL COMMENT 'リクエスト日時',
  `response_time` datetime NOT NULL COMMENT 'レスポンス日時',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='操作ログ';
EOL;
    $connection->execute($query);

    $query = <<<EOL
CREATE TABLE IF NOT EXISTS `operation_logs_hourly` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `target_time` datetime NOT NULL COMMENT '対象日時',
  `summary_type` varchar(20) NOT NULL COMMENT '集計タイプ',
  `groupedby` varchar(255) DEFAULT NULL COMMENT 'グループ元',
  `counter` int(11) NOT NULL COMMENT 'カウンタ',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='操作ログの集計(1時間毎)';
EOL;
    $connection->execute($query);

    $query = <<<EOL
CREATE TABLE IF NOT EXISTS `operation_logs_daily` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `target_ymd` date NOT NULL COMMENT '対象日',
  `summary_type` varchar(20) NOT NULL COMMENT '集計タイプ',
  `groupedby` varchar(255) DEFAULT NULL COMMENT 'グループ元',
  `counter` int(11) NOT NULL COMMENT 'カウンタ',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='操作ログの集計(日毎)';
EOL;
    $connection->execute($query);

    $query = <<<EOL
CREATE TABLE IF NOT EXISTS `operation_logs_monthly` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `target_ym` int(6) NOT NULL COMMENT '対象年月',
  `summary_type` varchar(20) NOT NULL COMMENT '集計タイプ',
  `groupedby` varchar(255) DEFAULT NULL COMMENT 'グループ元',
  `counter` int(11) NOT NULL COMMENT 'カウンタ',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='操作ログの集計(月毎)';
EOL;
    $connection->execute($query);
  }
}

__invokeで渡される$requestからリクエストURLやユーザーエージェントなどの記録する情報は大体取得できました。
プラグインの導入のときの手順を減らすという名目でリクエストの度にCREATE TABLE IF NOT EXISTS [table_name]を実行してしまっています。。ここもう少し改善したいところ。

またすべてのリクエストを記録するとデバッグキットのサブリクエストなんかも記録されてしまうため、InstanceConfigTraitを利用して除外設定を行うオプションを用意しています。exclude_urlsオプションに除外するURLを配列で設定することで、前方一致なURLはoperation_logsテーブルに記録されなくなります。

アクセスの集計について

集計処理はbakeタスククラスやShellクラスではなく、コンソールコマンドクラスを使用して作成してみました。
cronに登録して1日1回呼び出すことで、対象日のデータを集計してテーブルに記録するようなイメージです。
記録したデータを集計して以下の3テーブルに保管します。

CREATE TABLE IF NOT EXISTS `operation_logs_hourly` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `target_time` datetime NOT NULL COMMENT '対象日時',
  `summary_type` varchar(20) NOT NULL COMMENT '集計タイプ',
  `groupedby` varchar(255) DEFAULT NULL COMMENT 'グループ元',
  `counter` int(11) NOT NULL COMMENT 'カウンタ',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='操作ログの集計(1時間毎)';

CREATE TABLE IF NOT EXISTS `operation_logs_daily` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `target_ymd` date NOT NULL COMMENT '対象日',
  `summary_type` varchar(20) NOT NULL COMMENT '集計タイプ',
  `groupedby` varchar(255) DEFAULT NULL COMMENT 'グループ元',
  `counter` int(11) NOT NULL COMMENT 'カウンタ',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='操作ログの集計(日毎)';

CREATE TABLE IF NOT EXISTS `operation_logs_monthly` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `target_ym` int(6) NOT NULL COMMENT '対象年月',
  `summary_type` varchar(20) NOT NULL COMMENT '集計タイプ',
  `groupedby` varchar(255) DEFAULT NULL COMMENT 'グループ元',
  `counter` int(11) NOT NULL COMMENT 'カウンタ',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='操作ログの集計(月毎)';
コンソールコマンドクラスについて

コンソールコマンドクラスもプラグインやミドルウェアなんかと同様にbakeで空のクラスを作成できます。

cake bake command HourlySummary --plugin=OperationLogs --no-test
cake bake command DailySummary --plugin=OperationLogs --no-test
cake bake command MonthlySummary --plugin=OperationLogs --no-test

以下はoperation_logsテーブルに記録されているアクセスデータを日毎で集計するコマンドクラスです。1時間毎と月毎の集計処理も大体似たような感じになっています。

<?php
namespace OperationLogs\Command;

use Cake\Console\Arguments;
use Cake\Console\Command;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\ORM\TableRegistry;
use Cake\Utility\Hash;
use Cake\Core\Configure;

/**
 * 日毎集計コマンド
 * @author tikuwa
 *
 * @property \OperationLogs\Model\Table\OperationLogsTable $OperationLogs
 * @property \OperationLogs\Model\Table\OperationLogsDailyTable $OperationLogsDaily
 */
class DailySummaryCommand extends Command
{
  private $start_msg = "############ daily summary command start. ##############";
  private $end_msg   = "############ daily summary command end.   ##############";

  public function __construct() {
    $this->OperationLogs = TableRegistry::getTableLocator()->get('OperationLogs');
    $this->OperationLogsDaily = TableRegistry::getTableLocator()->get('OperationLogsDaily');
  }

  /**
   * 集計処理
   * {@inheritDoc}
   * @see \Cake\Console\Command::execute()
   */
  public function execute(Arguments $args, ConsoleIo $io)
  {
    $io->out($this->start_msg);

    // 集計対象日
    if ($args->hasOption('target_ymd')) {
      try {
        $target_ymd = new \DateTime($args->getOption('target_ymd'));
        $target_ymd = $target_ymd->format('Y-m-d');
      } catch (\Exception $e) {
        $io->error('target_ymd is invalid.');
        $this->abort();
      }
    } else {
      // 対象日が未指定のときは現在日-1
      $target_ymd = new \DateTime();
      $target_ymd = $target_ymd->modify('-1 days')->format('Y-m-d');
    }
    $io->out("target_ymd = {$target_ymd}");

    // 集計対象データを取得
    $operation_logs = $this->OperationLogs->find()->select(['id', 'client_ip', 'user_agent', 'request_url'])->where([
        'request_time >=' => $target_ymd . " 00:00:00",
        'request_time <=' => $target_ymd . " 23:59:59"
    ])
    ->enableHydration(false)
    ->toArray();
    if ($operation_logs == null || count($operation_logs) <= 0) {
      $io->out("operation_logs not found.\n{$this->end_msg}");
      $this->abort();
    }
    $operation_logs_count = count($operation_logs);
    $io->out("operation_logs {$operation_logs_count} records found.");

    // いったん対象日のデータを全削除
    $this->OperationLogsDaily->deleteAll(['target_ymd' => $target_ymd]);

    // 集計データを作成
    $operation_logs_daily_entities = [];

    // 集計(全体)
    $operation_logs_daily_entities[] = $this->OperationLogsDaily->newEntity([
        'target_ymd' => $target_ymd,
        'summary_type' => OL_SUMMARY_TYPE_ALL,
        'groupedby' => null,
        'counter' => $operation_logs_count
    ]);

    // IPアドレス/ユーザーエージェント/リクエストURLごとの集計データを作成
    foreach (Configure::read('OperationLogs.summary_types') as $summary_type => $summary_column) {
      if ($summary_type == OL_SUMMARY_TYPE_ALL) {
        continue;
      }
      $grouped_logs = Hash::combine($operation_logs, '{n}.id', '{n}', "{n}.{$summary_column}");
      foreach ($grouped_logs as $groupedby => $grouped_data) {
        // ユーザーエージェントなんかは空のパターンがある。
        // 空の時はHash関数のグルーピングによって0となるので空文字に置き換える
        if ($groupedby === 0) {
          $groupedby = '';
        }
        $operation_logs_daily_entities[] = $this->OperationLogsDaily->newEntity([
            'target_ymd' => $target_ymd,
            'summary_type' => $summary_type,
            'groupedby' => $groupedby,
            'counter' => count($grouped_data)
        ]);
      }
      $io->out("operation_logs groupd by {$summary_column} to makes " . count($grouped_logs) . " records.");
    }

    // 保存
    $this->OperationLogsDaily->saveMany($operation_logs_daily_entities);
    $io->out("operation_logs_daily " . count($operation_logs_daily_entities) . " records registered.");
    $io->out($this->end_msg);
  }

  /**
   * オプションパーサー
   * {@inheritDoc}
   * @see \Cake\Console\Command::buildOptionParser()
   */
  protected function buildOptionParser(ConsoleOptionParser $parser)
  {
    $parser
    ->addOption('target_ymd', [
        'help' => 'input summary target date.',
    ]);

    return $parser;
  }
}

コマンドクラスはオプションパーサーで自在にパラメータを定義することが出来るあたりが便利。コマンドのhelpオプションで設定可能なパラメータとその説明も表示することができます。
上に挙げたDailySummaryCommand.phpは以下のようなコマンドで呼び出します。

bin/cake daily_summary --target_ymd=2020-02-18

コマンドの実行結果やabortによるエラー処理なんかはコマンドプロンプトだと単純に表示されるだけですが、LinuxOS上で実行してみたところエラーは赤字になったりする模様。

他にもコンソールコマンドを実行する際、オプション名を間違ったりすると自動で近似するオプション名を候補に出してくれたりするみたいです。

集計データを元にアクセス数のチャートを表示する

今回は当OperationLogsプラグインを導入してるミリシタのガシャシミュレータの管理画面にAccessLogsというコントローラクラスを作ってみました。
画面で検索対象の日付と、集計間隔を選択することで対象の集計データを取得しグラフ表示を行います。プログラムはリポジトリ内で見れるのでここでは省略。

全体、IPアドレス別、ユーザーエージェント別、リクエストURL別で表示してみました。
グループが10を超える場合は「その他」というグループにまとめて表示しています。
1時間毎、日毎、月毎のデータを取得する関数をプラグイン内のOperationLogsUtilsというクラスに作成しています。集計データをグラフ表示用に良い感じに取得できるようになってるかと思います。

以下は全体とIPアドレス別のチャート

以下はユーザーエージェント別とリクエストURL別のチャートです。

チャートの表示はChart.jsを使用しています。
バーチャートの色はAdobeのColor wheel, a color palette generator | Adobe Colorを利用して作成しました。

まとめ

インストールしてちょこっと設定するだけで使用可能なアクセス集計プラグインが出来たような気がする?
コンソールコマンドの使い勝手が結構良い。
オプションの追加が簡単。
ミドルウェアを自作する際の初期構築はbakeするのが良い。最初公式ドキュメント見ながら手打ちで書いてたらよくわからないことになったりした。

ちなみに今回のようなアクセス記録の処理は、ミドルウェアを使わない場合でも一応可能かと思います。具体的にはAppControllerのような共通で読み込まれるコントローラークラスのbeforeFilter、afterFilterを使えばいいと思います。
ただし、そちらの場合はsrc/Application.phpでのミドルウェアのロード、bootstrap.phpに加えて既存の各コントローラークラスの継承の辺りをいじる必要があるので作りこんだシステムでは使い勝手が悪いかと思います。

その他、メモ

  • Packagistについて調べる。
  • プラグイン開発元のCakePHP3のバージョンアップを行う(3.6→3.7→3.8)
  • CakePHP4について勉強する。
  • 集計処理について見直す。
  • 記録テーブル集計テーブル共にレコードが増えたときに問題ないか確認する。
  • 都度CREATE TABLEしてるとこどうにかする。
タイトルとURLをコピーしました