リクエストオブジェクトに、パラメータを値オブジェクトに変換するメソッドを追加する。

このようなコーディングを実現します。値オブジェクトの生成をコントローラーに書く必要がなくなります。

<?php
$startDate = $request->dateParam('startData');

PHPだと、HTTPリクエストのインターフェースはPSR-7で決まっています。

PSR-7: HTTP message interfaces - PHP-FIG

必要最低限しかメソッドがないため、私は独自にヘルパーメソッドを追加しています。今回使用するベースクラスはzendframework/zend-diactorosのServerRequestクラスです。

<?php
declare(strict_types=1);

use Zend\Diactoros\ServerRequest;

final class MyServerRequest extends ServerRequest
{
    public function param(string $name, $default = null)
    {
        return $this->getParsedBody()[$name] ??
            $this->getQueryParams()[$name] ??
            $default;
    }

    public function hasParam(string $name) : bool
    {
        return isset($this->getParsedBody()[$name]) || isset($this->getQueryParams()[$name]);
    }

    public function intParam(string $name): ?int
    {
        if (!$this->hasParam($name)) {
            return null;
        }
        return is_int($this->param($name));
    }

    public function boolParam(string $name): bool
    {
        $value = $this->param($name);
        $trues = [true, 1, '1', 'true', 'yes', 'ok'];
        return in_array($value, $trues, true);
    }
}

HTTPリクエストがGETかPOSTを気にせず取得できるparamメソッドを用意しました。このメソッドをラップして、戻り値の型を変換して返すヘルパーメソッドも用意しています。

boolParamを使えば、truthyな値をboolに変換できます。プロジェクトの立ち上げ時なら、値は[true, 1, ...]ではなく、1つに統一すれば良いかもしれません。boolParamが後付なら、このように色々な値をboolに統一していくことができます。

この値の統一ですが、プリミティブ値以外に値オブジェクトを返すヘルパーメソッドを用意すると、さらに便利だなと思いました。

<?php

public function dateParam(string $name, string $defaultDate = null): ?Carbon
{
    $value = $this->param($name);
    if (empty($value)) {
        if (is_null($defaultDate)) {
            return null;
        }
        $value = $defaultDate;
    }

    // a:「全角」英数字を「半角」に変換(コロンも対象)
    // s:「全角」スペースを「半角」に変換(U+3000 -> U+0020)
    $value = mb_convert_kana($value, 'as');
    $value = str_replace('', '-', $value);
    try {
        return new Carbon($value);
    } catch (UnexpectedValueException $e) {
        return null;
    } catch (InvalidDateException $e) {
        return null;
    } catch (Exception $e) {
        return null;
    }
}

public function pageParam(): Page
{
    $value = $this->intParam('page') ?: 1;
    if ($value < 1) {
        $value = 1;
    }
    return new Page($value);
    }
}

Pageはページ数の値オブジェクトです。引数でkeyを渡すをやめて、メソッド内でハードコードすれば全体で統一できます。

コントローラを増やしていくたびに、徐々に追加していくとスッキリするでしょう。特定ページでしか使わない固有の値は、paramメソッドでその場で生成すれば良いと思います。

param系のメソッドは数が多くなると思うのでトレイトでまとめておくと良いかもしれません。

モデルクラスに条件判定メソッドを定義して、仕様をコードにまとめる。

※ コンストラクタは省略しています。

<?php
declare(strict_types=1);

final class Post
{
    const STATUS_PUBLIC = 1;
    const STATUS_CLOSE = 0;

    /** @var int */
    private $status;

    public function status(): int
    {
        return $this->status;
    }
}

ブログ記事を表すPostモデルを例に使います。$post = Post::find(1);とDBから取得できるとします。

statusカラムに記事の公開状態を数値で管理します。

<?php
if ($post->status === Post::STATUS_PUBLIC) {
    // 記事が公開されている時の処理
}

記事が公開されているかを確認する時に、クラスの外で定数を比較していませんか? 条件はメソッドにして分かりやすくしましょう。Postクラスに下記を定義します。 クラス定数をprivateにすることも重要です。クラス外ではisPublicでしか定数が比較されないことを保証します。

<?php
// 一部省略
final class Post
{
    private const STATUS_PUBLIC = 1;
    private const STATUS_CLOSE = 0;

    public function isPublic(): bool
    {
        return $this->status === self::STATUS_PUBLIC;
    }
}

If文はこう変わります。

<?php
if ($post->isPublic()) {
    // ...
}

ここから理由について掘り下げていきます。

メソッドで条件を1行だけに留める

記事はどのような判断で、「公開」と見なすでしょう。アップデートに伴い、postのstatusが増える可能性があります。

  1. 下書き
  2. フォロワーだけに公開
  3. 特定の選択した人にだけ公開

このように増えたとしても、クラス外ではisPublicを使っていれば、変更はありません。もし、直接書いていた場合は、&&||を使いビシネスロジックが読みづらくなってしまうでしょう。

英文のように読みやすいかが良いメソッドの判断基準です。主語と動詞を意識します。上記の追加したステータスによっては、isPublicをisAllPublicなどにリネームする必要があるかもしれません。

考える範囲が狭まり、PRが見やすい

isPublicなど条件判定が複雑なっていたとしても、このメソッドの定義を見る時は「記事が公開の時のみtrueを返すか?」だけを考えればよく、isPublicを呼び出す側のビジネスロジックは考える必要はありません。もちろんグローバル変数など使っておらず、きちんと互いに影響のない独立したisPublicを作ることが条件です。その条件を満たしているかを見れば良いことになります。

そして、一度独立したisPublic作り運用すれば、今後はこのメソッドを使ってもらえれば、判定条件までレビューする必要がなくなります。
※ 「公開する判断」という仕様が変わる時は別です。

もしisPublicがなければ、毎回クラス定数との比較などを見る必要が出てきます。複雑になるほど、! / && / || / ()が多用され、毎回全てが正しいかを確認する必要があります。それなら、誰かが時間をかけて実装したisPublicを使い回しましょう。

データの仕様が分かる・まとまる

Postクラスを見るとステータスがどのように使われているか分かります。もしかしたら、ステータスは公開範囲外でも使われているかもしれません。しかし、データがある場所、つまりPostクラスを見れば分かります。

クラスのデータ、さらに細かく言うと各値の仕様をまとめるためにも、クラスにメソッドをまとめます。別でドキュメントを用意するより、PHPDocなどで書いた方が確実で、小コストで保守できます。

一番良いのは、間違えて使うのが難しいコードです。そのために、メソッドを用意し、引数もできるだけ少なくすると良いでしょう。

プロパティ、1つの値をクラス化してしまう

StatusをPostStatusというクラスにして、PostクラスはPostStatusのインスタンスを保持するようにすると、さらに見やすくなります。

<?php

final class Post
{
    /** @var PostStatus */
    private $status;

    public function status(): PostStatus
    {
        return $this->status;
    }
}

final class PostStatus
{
    const PUBLIC = 1;
    const CLOSE = 0;

    /** @var int */
    private $value;

    public function __construct(int)

    public function value(): int
    {
        return $this->value;
    }

    public function isPublic(): bool
    {
        return $this->value === self::PUBLIC;
    }
}

if ($post->status()->isPublic()) {
}

以前のisPublicを残して、ショートカットとして使うの良いと思います。私はプロキシメソッドと呼んでいます。PostStatusのメソッドが増えるたびに、用意する必要が出るかもしれないため、面倒かもしれません。

<?php
final class Post
{
    /** @var PostStatus */
    private $status;

    public function isPublic(): bool
    {
        return $this->status->isPublic();
    }
}

このようにクラスを分けると、記事の公開状態だけの仕様を考える事ができます。Postには他にも色々なメソッドが増えて、どんなメソッドがあるか分かりづらくなってきます。

インスタンスプロパティが増えるほど、互いのメソッドに影響が出る可能性もあります。「他のメソッドで$statusが変更されていないか?」を考える必要も減らすためにも、PostStatusがあると便利です。少なくともPostStatus内では、Postクラス内のことを考える必要はありません。

symfony/processでコールバックが実行されるタイミングを知らないと、並列処理で標準出力がうまくいかない。

symfony/processをマルチプロセスにして、並列ストリーム出力する。 - mitsuru793のブログ

symfony/processを使い、並列処理を実装しました。次にProcessのコレクションクラスを作ったのですが、コレクション全体のisRunningを確認するとメソッドで詰まりました。

このライブラリはプロセスの情報を更新するメソッドを、よく呼び出します。これが呼び出されないと標準出力のためのコールバックが実行されません。それではコードを見ていきましょう。

<?php
declare(strict_types=1);

use Symfony\Component\Process\Process;

require_once __DIR__ . '/vendor/autoload.php';

$script = __DIR__ . "/tmp.sh";

file_put_contents($script, <<<'BASH'
echo "start $1"
sleep $2
echo "end $1"
BASH
);

$processes = [
    new Process(['bash', $script, 1, 3]),
    new Process(['bash', $script, 2, 1]),
];

foreach ($processes as $i => $process) {
    $process->start(function (string $type, string $data) {
        echo 'in callback';
    });
}

このようにstartには、出力のバッファを処理するコールバックが渡せます。runメソッドにも渡せます。 このコールバックが実行されるタイミングは、Process->readPipesというprivateメソッドが呼び出された時です。ここでしか呼び出されません。コールバックをセットしても、内部でこのメソッドが呼び出されない限りは、上記のコードは標準出力しません。

<?php
// 呼び出し例
$this->readPipes($running && *$blocking*, ‘\\’ !== \*DIRECTORY_SEPARATOR*|| !$running);
<?php
/**
 * Reads pipes, executes callback.
 *
 * @param bool $blocking Whether to use blocking calls or not
 * @param bool $close    Whether to close file handles or not
 */
private function readPipes(bool *$blocking*, bool *$close*)
{
    $result = $this->processPipes->readAndWrite(*$blocking*, *$close*);

    $callback = $this->callback;
    foreach ($result as $type => $data) {
        if (3 !== $type) {
            $callback(self::*STDOUT*=== $type ? self::*OUT*: self::*ERR*, $data);
        } elseif (!isset($this->fallbackStatus['signaled'])) {
            $this->fallbackStatus['exitcode'] = (int) $data;
        }
    }
}

readPipesは次のメソッドで呼び出されます。

  • wait
  • updateStatus

waitはstartした外部コマンドの実行が終わるのをwhileで待機します。runメソッドで、startに続き呼び出されています。

updateStatusの呼び出し元は次のメソッドです。

  • start
  • wait
  • getExitCode
  • isRunning
  • isTerminated
  • getStatus
  • readPipesForOutput(これだけprivate)

Startメソッドを使った際は、waitは呼び出されないため上記のどれかのメソッドを通じてupdateStatusを呼び出されないとコールバックが実行されません。

Processのコレクションを作る際に次の間違いをしました。

<?php
final class Processes
{
    /** @var Process[] */
    public $items;

    public function __construct(array $items)
    {
        $this->items = $items;
    }

    public function areSomeRunning()
    {
        foreach ($this->items as $process) {
            /** @var Process $process */
            if ($process->isRunning()) {
                return true;
            }
            return false;
        }
    }
}

1つでもプロセスが起動中ならtrueを返すareSomeRunning()を作りました。返す条件は正しいですが、1つ間違いがあります。return true;でbreakすると、他のプロセスのisRunningが呼び出されないので、updateStatusが実行されません。つまり、他のプロセス出力のコールバックが実行されません。標準出力されないことになります。

<?php
public function areSomeRunning()
{
    $isRunning = false;
    foreach ($this->items as $process) {
        /** @var Process $process */
        if ($process->isRunning()) {
            $isRunning = true;
        }

        return $isRunning;
    }
}

返すbool値が分かったとしても、全プロセスの標準出力のために最後までループを回す必要があります。

symfony/processをマルチプロセスにして、並列ストリーム出力する。

プロセスの制御には組み込み関数ではなく、symfony/processを使います。内部では、組み込み関数proc_openが使われています。

プロセスの実行には2種類のメソッドがあります。

  • run(同期的で、内部でwaitメソッドを呼び出す)
  • start(続いてwaitメソッドを呼び出す、runと同じになる)

同時のプロセスを動かしたいので今回はstartを使います。runを使うと、1つのプロセスが終わるまで次のプロセスは実行されません。

<?php
declare(strict_types=1);

use Symfony\Component\Process\Process;

require_once __DIR__ . '/vendor/autoload.php';

$script = __DIR__ . "/tmp.sh";

file_put_contents($script, <<<'BASH'
echo "start $1"
sleep $2
echo "end $1"
BASH
);

$processes = [
    new Process(['bash', $script, 1, 3]),
    new Process(['bash', $script, 2, 1]),
];

foreach ($processes as $i => $process) {
    $process->start(); // bashを実行
    sleep(1); // プロセスが順番に起動することを保証
}

do {
    $isRunning = false;
    foreach ($processes as $i => $process) {
        echo $process->getIncrementalOutput(); // 標準出力
        if ($process->isRunning()) {
            $isRunning = true;
        }
    }
} while($isRunning);

unlink($script);

標準出力

start 1
start 2
end 2
end 1

1個目のプロセスは3秒かかり、2個目は1秒です。1個目のプロセスをstartした後にsleep(1)を入れておかないと、2個目が先に起動することがあります。おそらくstartメソッドを先に呼び出しても、実際にbashが実行されるまでほんの少し時間がかかってしまうことがあるのではと思います。

プロセスが出力した文字列は、getOutputではなくgetIncrementalOutputを使います。getOutputだとそれまでに出力されたものが、毎回全て出力されてしまいます。トータルのバッファを出力ということですね。getIncrementalOutputだと、前回のこのメソッド呼び出しから新たに出力されたものを取得できます。

実はgetIncrementalOutputを使わなくても、run, startメソッドにcallbackを渡して実現可能です。

<?php
foreach ($processes as $i => $process) {
    $process->start(function (string $type, string $data) {
        echo $data;
    });
    sleep(1);
}

do {
    $isRunning = false;
    foreach ($processes as $i => $process) {
//        echo $process->getIncrementalOutput();
        if ($process->isRunning()) {
            $isRunning = true;
        }
    }
} while($isRunning);

コールバックの引数$typeはProcessのクラス定数です。

<?php
class Process implements \IteratorAggregate
{
    const ERR = 'err';
    const OUT = 'out';
}

if ($type === Process::ERR) { echo $data; }

このように標準・エラー出力を切り替えることができます。

注意点として、コールバックが呼び出させれるのはプロセスに出力があった時です。

<?php

file_put_contents($script, <<<'BASH'
sleep $2
BASH
);

foreach ($processes as $i => $process) {
    $process->start(function (string $type, string $data) {
        echo 'in callback';
    });
}

一部省略しています。bashsleepだけにして、常にin callbackと出力するようにcallbackを設定しているのですが何も出力されません。

レアジョブは、レッスンチケットを買うより1日レッスンの定額コースの方がお得

現在は25分1レッスンを利用しています。レッスンチケットを購入するか、50分コースにプラン変更した方が良いかを考えてみました。

毎日のレッスン回数

25分1レッスン
5,800 / (30 ** 1) = 193.33

50分2レッスン
9,700 / (30 ** 2) = 161.6…

100分4レッスン
1,6000 / (30 ** 4) = 133.3…

レッスンチケット

13枚通常価格
6000 / 13 = 461.5…

13枚キャンペーン価格
6000 / 15 = 400

25分定額と通常のレッスンチケットを合わせた場合

1万円を超えます。
5,800 + 6,000 = 11, 800

50分定額とのレッスン回数の差は17回。
30 - 13 = 17

レッスンチケットより定額の方が得

50分定額を利用した方が2100円も安かったです。
11, 800 - 9,700 = 2,100

プランは途中変更した場合は、日割り分の返金があるようなので好きな時に変えれますね。
コースや料金プランの変更はできますか。 | FAQ | オンライン英会話のレアジョブ

レアジョブは、レッスンチケットを買うより1日レッスンの定額コースの方がお得

現在は25分1レッスンを利用しています。レッスンチケットを購入するか、50分コースにプラン変更した方が良いかを考えてみました。

毎日のレッスン回数

25分1レッスン
5,800 / (30 ** 1) = 193.33

50分2レッスン
9,700 / (30 ** 2) = 161.6…

100分4レッスン
1,6000 / (30 ** 4) = 133.3…

レッスンチケット

13枚通常価格
6000 / 13 = 461.5…

13枚キャンペーン価格
6000 / 15 = 400

25分定額と通常のレッスンチケットを合わせた場合

1万円を超えます。
5,800 + 6,000 = 11, 800

50分定額とのレッスン回数の差は17回。
30 - 13 = 17

レッスンチケットより定額の方が得

50分定額を利用した方が2100円も安かったです。
11, 800 - 9,700 = 2,100

プランは途中変更した場合は、日割り分の返金があるようなので好きな時に変えれますね。
コースや料金プランの変更はできますか。 | FAQ | オンライン英会話のレアジョブ

無料レッスンは残ってた

レアジョブ4回目を終えました。自分のトータルのレッスン回数と時間が確認できるので便利です。

今日は1回のレッスンで2個目テキストにまで入ることができました。少しずつ慣れてきていることを実感しました。

無料レッスンは残ってた

f:id:mitsuru793:20180802033402j:plain

レッスンチケットの有効期限は何日間ですか? | FAQ | オンライン英会話のレアジョブ

レッスンチケットに変換されていました。発行日から30日経つとチケットは消えるそうなので注意です。今は毎日25分なので、1レッスンまでチケット無しで予約できます。同日にさらにレッスンを予約する場合にチケットを消費するようです。

※毎日25分プランの場合は1レッスン、毎日50分プランの場合は2レッスンとなります
via: レッスンチケットの予約方法を教えてください。 | FAQ | オンライン英会話のレアジョブ

50分プランは1回で50分行うかと思いましたが、2回に分かれるみたいですね。その方が消化しやすくて良いと思います。