コマンドラインで動作するwikipediaのログ解析システムを作る

## はじめに

コマンドライン上で動作するwikipediaのログ解析システムを作成しました。
こちらはプログラミング学習サービス、独学エンジニアの課題への取り組みです。

https://dokugaku-engineer.com/

データベースのパートの最後の課題として出題されるもので、模範解答はありません。
多言語で構成されたデータベースを、プログラム(PHP)を通じて扱うことで、データベースやPHPへの理解が深まりました。
作成に当たってつまずいた所を中心に、開発をまとめました。

作成したものはGithubで公開しております
https://github.com/progundeike/wiki-log-analysis

実行中のキャプチャー
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2231801/9808b134-abab-e292-bb8e-a625bc672105.png" width="800px"/>

## 実装する機能

  公開されているWikipediaのアクセスログを用いて、

  タスク1:　指定した記事数の閲覧数の多い順に、「ドメインコード」、「ページタイトル」、「閲覧数」を表示する

  タスク2:　指定したドメインコードを人気順にソートし、ドメインコードと合計閲覧数を表示する

## 解析に使用するデータ

wikipediaで公開されている、アクセスログを集計したファイルをダウンロードして使用します。
データは2021年12月1日のものを使用しました。下記URLからダウンロードできます。
https://dumps.wikimedia.org/other/pageviews/2021/2021-12/

### データの概要

アクセスログはページごとの閲覧数を1時間ごとに集計したものがgzipで圧縮されています。
データはスペースで区切られ、以下の4つカラムからなる行で構成されています。

1. ドメインコード
    リクエストのドメイン名を省略した形式で表記

2. ページタイトル
    リクエストされたURLの/wiki/の後のタイトル部分

3. 閲覧回数
    集計時間内にこのページが閲覧された回数

4. 総レスポンスサイズ
    集計時間内にこのページへのリクエストによって引き起こされたレスポンスサイズの合計。今回は参照しません。

サンプル(先頭5行)

```text
aa Main_Page 4 0
aa Wikipedia 1 0
aa Wikipedia:Statistics 1 0
aa.b Main_Page 1 0
aa.d Main_Page 4 0
```

今回使用したファイルでは、このようなデータが約510万行入っています。
公開されているデータについての全体の解説、テーブル定義の詳細は下記URLをご参照ください。
https://dumps.wikimedia.org/other/pageviews/readme.html
https://wikitech.wikimedia.org/wiki/Analytics/Data_Lake/Traffic/Pageviews

## 使用技術

Docker 20.10.20 / Docker compose 2.12.1
PHP 8.1.12
MySQL 8.0.31

## 環境構築

Docker composeを使用し、MySQLの動作するDBコンテナをPHPの動作するappコンテナから操作する構成にしています。

<details><summary>主なディレクトリ構成</summary>

```text
wiki-log-analysis
├── app
│   ├── log_file
│   │   └── page_views  //　解析するログファイル
│   ├── phpcs.xml       
│   ├── phpmd.xml
│   ├── phpstan.neon
│   ├── src
│   │   ├── Analysis.php
│   │   ├── DomainCodeRankingTask.php
│   │   ├── SetupDB.php
│   │   ├── Task.php
│   │   └── ViewsRankingTask.php
│   ├── vendor
│   └── wiki_log_analyze.php // プログラムの起動
├── docker
│   ├── db  // MySQLのコンテナ
│   │   ├── Dockerfile
│   │   ├── db-variables.env
│   │   └── my.cnf
│   └── php  // PHPのコンテナ
│       ├── Dockerfile
│       └── php.ini
└── docker-compose.yml

```

</details>

また、PHPStan, PHP MessDetector, PHP CodeSnifferを導入し、PHPのコーディング規約や、潜在的な問題点を確認しました。

## テーブル設計

アクセスログは各ページの閲覧数を集計したものなので、1つのドメインコードに対して、ページタイトルが重複することはありません。
そのため、ドメインコードとページタイトルを複合主キーとしたテーブルを作成しました。

```sql
CREATE TABLE page_views (
    domain_code VARCHAR(255),
    page_title VARCHAR(500),
    count_views INTEGER,
    total_response_size INTEGER,
    PRIMARY KEY (domain_code, page_title)
)
```
### インデックスの作成

検索処理を高速化するために、MySQLの実行計画を確認しました。ドメインコードとページタイトルを複合主キーとすることで、タスク2の検索は高速化されましたが、タスク１には反映されていません。そこで閲覧回数にインデックスを張ることで、タスク１、タスク２共に高速化できました。

```sql
CREATE INDEX
    count_views_index
ON
    page_views (count_views)
```

## MySQLの設定

### 照合順序(コレーション)の変更
複合主キーとして、データをインポートする際にハマりました。ページタイトルのアルファベットの大文字と小文字のみが異なるデータがいくつも存在します。
例えば

```text
  en Abraham_Lincoln 198 0
  en ABRAHAM_LINCOLN 1 0
```

MySQLのデフォルトの設定だと、アルファベットの大文字小文字を区別しません。そのため上記の様なデータはインポート時に、主キーの重複となりwarningsが発生し、データが欠落します。
これはMySQL8.0の文字の照合順序が、デフォルトのutf8mb4_0900_ai_ciを使用することから来ています。今回はMySQLの設定ファイルであるmy.cnfにcollation_server=utf8mb4_bin
を追記し、大文字と小文字を区別し全てのデータをインポートすることができました。

:::note info
テーブル作成時にpage_titleに対して、 CHARACTER SET BINARYを追記しBINARY属性を付与する方法でも対応できました。
参考:[MySQLのchar,varcharは大文字小文字を区別しない](https://qiita.com/kubotak/items/a55ed6cd94c686e6aabf)
:::

### ローカルファイルのインポート

デフォルトではローカルファイルのインポートは許可されていません。設定をせずにインポートしようとすると、下記のエラーが出ます。

```text
Loading local data is disabled; this must be enabled on both the client and server sides
(ローカルデータの読み込みは無効です。クライアント側とサーバー側の両方で有効にする必要があります。)
```
そのためmy.cnfに許可設定を追記しました。また、ローカルファイルのインポートにはPDO接続時にもオプションの設定が必要です(後述)。

```text
[mysqld]
local-infile=1
```

https://dev.mysql.com/doc/refman/8.0/ja/load-data-local-security.html

### バックスラッシュが意図せずエスケープされる
今回のデータではインポートする際に、" "(半角スペース)でカラムの区切りを判定し、改行で行の終わりであることを判定しています。しかし、ページタイトル中に" \ "(バックスラッシュ)が含まれていると、意図せずカラムの区切りを判定し、データが欠落してしまいました。
sql_modeの設定をデフォルトに加えて、"NO_BACKSLASH_ESCAPES"をmy.cnfに設定することで、全てのデータをインポートすることができました。

```text
[mysqld]
sql_mode = NO_BACKSLASH_ESCAPES,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
```

https://dev.mysql.com/doc/refman/8.0/ja/sql-mode.html

## PDO

PHPからのデータベース操作はPHP標準のPDOクラスを使用しました。

### PDO接続時にローカルファイルからのインポートを許可

my.cnfにローカルファイルのインポートの許可を設定しましたが、DB接続時にも設定が必要です。
PDOインスタンス生成時に連想配列として付与しました。

```php
$pdo = new PDO(
    $dsn,     // "mysql:host="db";dbname="database";charset=utf8mb4"
    $this->dbUser,
    $this->dbPassword,
    [PDO::MYSQL_ATTR_LOCAL_INFILE => 1]
);
```

:::note info
PDOを使用せず、DBコンテナ内で作業する際などは、MySQL接続時に--local-infile=1のオプションを付与しました。
```text
mysql -u user -D database -p --local-infile=1
```
:::

## 手をかけたポイント

### オブジェクト指向

今回実装するタスクは２つでしたが、オブジェクト指向の勉強のために、後からタスクの追加や仕様変更をしやすいような設計を心がけました。タスクインターフェースを用意し、タスク選択の前後の処理が選択されるタスクに依存しないような処理になるよう記述しました。


### 起動時にDBがインポート済みか確認

プログラム起動時に、「DBをインポートしますか？ 'y' or 'n'」 というメッセージが表示されますが、このメッセージはDBにデータがない場合は表示されません。
データが存在しない場合、選択画面を表示せずにインポートを開始します。解析するログファイルの変更等を想定し、データがある場合は選択とする仕様にしました。

### 繰り返し処理

解析するタスクを選ぶ際や、タスクの解析に必要な引数を標準入力から受け取る際に、想定外の入力を受け取ると正しい数値が得られるまでエラーメッセージと入力の受け取りを繰り返します。

<details><summary>実行例</summary>

```text
解析するタスクナンバーを入力してください。終了するには0を入力してください
タスク1: 記事数を指定して、閲覧数の多い順に表示します
タスク2: ドメインコードを入力し、閲覧数の多い順に合計閲覧数を表示します
a
タスクナンバーの入力に誤りがあります
解析するタスクナンバーを入力してください。終了するには0を入力してください
タスク1: 記事数を指定して、閲覧数の多い順に表示します
タスク2: ドメインコードを入力し、閲覧数の多い順に合計閲覧数を表示します
9
タスクナンバーの入力に誤りがあります
解析するタスクナンバーを入力してください。終了するには0を入力してください
タスク1: 記事数を指定して、閲覧数の多い順に表示します
タスク2: ドメインコードを入力し、閲覧数の多い順に合計閲覧数を表示します

```

</details>

今回はこのような正しい入力が得られるまで繰り返すという、処理を多用しました。
while (true) { }で正しい処理の場合はbreakで抜ける処理が中心ですが、do-while構文で後判定して、繰り返す処理もあります。
ネットの記事を漁ると、do-while構文は可読性が下がるので控えるべきという意見も見当たります。正攻法が分からず、do-whileにしたり、whileにしてフラグ変数を追加してみたりを繰り返してしまいました。

## 終わりに

コードを書いたり、設計を考えているとあれやこれや機能をつけたくなりますし、リファクタリングも終わりが見えません。メソッドもより分割し単体テストを行うべきかとも思います。今回の設計では標準入力の受け取り方や、例外発生時にメッセージと共にプログラムを終了させる仕様であることから、主要な機能の単体テストを行えませんでした。単体テストを行えるようにメソッドを分割することが、実装する機能に対して過剰な設計にも思えましたので、今回は単体テストを見送りました。
今回のプログラムは一度ここで区切りをつけ、PHPやオブジェクト指向について理解を深められれば再度修正していきたいと思います。
ご指摘など頂けましたら幸いです。

## 参考


https://qiita.com/mpyw/items/b00b72c5c95aac573b71

https://mita2db.hateblo.jp/entry/2020/12/07/000000