中規模または大規模なチームでコードベースを共有すると、お互いのコードや使い方を理解するのが大変です。解決策は、一連のコーディング規約にのっとることで相手が読みやすいコードを書くことや、全員が知っているフレームワークを使うことなどがありますが、この対策では不十分なこともあります。
特に、少し前に書かれたアプリケーションの一部のバグを直したり、新たな機能を追加したりするときに不十分さを感じます。あるクラスをどう動作させたかったのか、ほかの機能とどう組み合わせて使うつもりだったのか、すべて覚えているのは容易ではなく、知らないうちに副作用やバグを作り込みがちです。
副作用やバグは品質保証ツールで見つかるはずですが、見逃してしまうこともあります。また、見つけてもコードを差し戻して改修するには長い時間がかかります。
対策方法を紹介しましょう。「ポカヨケ」で検索してください。
ポカヨケとは
ポカヨケの意味は「ポカ(ミス)をよける(防止する)」です。リーン生産方式の用語で、機械オペレーターのミスをなくすための仕組みを指します。日本語の「ポカヨケ」が「Poka Yoke」として世界で使われているのです。
ポカヨケは家電製品でも用いられます。たとえば、SIMカードです。SIMトレイには、決まった向きしか差し込めないよう左右非対称な形をしています。
ポカヨケできていないハードウェアの例がPS/2ポートです。キーボードコネクターとマウスコネクターがまったく同じ形をしています。色でしか区別できないので、コネクターを取り違えて間違ったポートに差してしまう恐れがあります。
ハードウェアに取り入れるポカヨケの概念を、プログラミングにも応用します。コードのパブリックインターフェイスを簡単に理解できるようにしたり、コードが間違った形で使われた場合は速やかにエラーを出したりします。当然だと思うかもしれませんが、できていないコードも山ほどあります。
ポカヨケでは、故意の悪用は防げません。ポカヨケの目的はあくまで偶発的なミスを防ぐことで、コードを悪用から守ることではありません。コードを利用する人がその気になれば、安全装置はいつでも外せます。
コードをミスから守る具体的な手段を見る前に、ポカヨケの仕組みを紹介します。一般に以下の2つのカテゴリーに分類されます。
- ミス予防
- ミス検出
ミスを早期に摘み取るための仕組みがミス予防です。インターフェイスや動作をわかりやすくして、コードが間違った形で利用されないようにします。決まった向きでしかSIMトレイに差し込めないSIMカードの例を思い返してください。
アプリケーションを監視して、潜在的なミスを見つけ、警告する。ミス検出はコードの外部にある仕組みです。たとえば、デバイスがPS/2ポートに正しく接続されているか監視し、間違っていればユーザーに「なぜ動作しないのかわかるように警告する」ソフトウェアのことです。コネクターは間違えて差し込めるので、ミスは防げませんが、ミスを検出しユーザーに警告することでミスを修正します。
ミス予防とミス検出をアプリケーションに実装する手法をいくつか紹介します。紹介する手法は一部にすぎません。ミスをなくす手段はほかにあるかもしれません。また、ポカヨケにかかる費用や、プロジェクトにとって価値があるかを確認することも大切です。アプリケーションの複雑さや規模によっては、コストが高くなりすぎこともあります。チームに最適な手法を見つけてください。
ミス予防の例
(スカラー)型宣言
型宣言はPHP 7で関数やメソッドのシグネチャーをミスから守る方法です。導入は簡単です。PHP 5ではタイプヒンティングとして知られていました。
関数の呼び出し時、引数の順序を取り違えないために、関数の引数に明確な型を与えます。
たとえば、以下のNotificationをユーザーに送ります。
<?php
class Notification {
private $userId;
private $subject;
private $message;
public function __construct(
$userId,
$subject,
$message
) {
$this->userId = $userId;
$this->subject = $subject;
$this->message = $message;
}
public function getUserId()
{
return $this->userId;
}
public function getSubject()
{
return $this->subject;
}
public function getMessage()
{
return $this->message;
}
}
型宣言がないと、間違った型の変数を注入してしまいます。これによりアプリケーションが壊れる恐れがあります。たとえば、$userIdはstring型だと考えるのが普通ですが、int型かもしれません。
間違った型を注入しても、アプリケーションがNotificationを使って処理を始めるまでエラーが発生しない可能性があります。エラーが発生してから受け取るエラーメッセージは予期しない型に関する謎めいた内容で、int型の代わりにstring型を注入している箇所を指摘するエラーメッセージではありません。
アプリケーションをできるかぎり早くクラッシュさせて、バグを早い段階で見つけたほうが良いことがよくあります。
今回は型宣言を追加することで、引数の型を間違えたときにPHPが停止し、フェイタルエラー(fatal error)の警告を受けます。
<?php
declare(strict_types=1);
class Notification {
private $userId;
private $subject;
private $message;
public function __construct(
int $userId,
string $subject,
string $message
) {
$this->userId = $userId;
$this->subject = $subject;
$this->message = $message;
}
public function getUserId() : int
{
return $this->userId;
}
public function getSubject() : string
{
return $this->subject;
}
public function getMessage() : string
{
return $this->message;
}
}
PHPのデフォルトの設定は不適切な引数を本来の型に自動変換します。そこで、strict_typesを有効にして、ミスがあればフェイタルエラーを発生させます。つまり、スカラー型宣言は理想的なポカヨケとはいえません。しかし、スカラー型宣言はミスを減らすための良い出発点です。strict_typesが無効でも、スカラー型宣言は引数に必要な型を指摘します。
さらに、メソッドで戻り値の型を宣言することで、関数を呼び出したときにどんな値が返されるのかわかりやすくなりました。
戻り値の型を明示的に宣言して、戻り値を処理するときに書くswitch文を減らせます。戻り値の型を明示的に宣言しなければ、メソッドはさまざまな型を返します。メソッドの利用者は特定のシナリオでどの型が何を返したのか確認する必要があります。こうしたswitch文の内容を忘れる可能性は高いため、検出が難しいバグを作り込む原因になります。戻り値の型を定義することで、このミスを防げます。
バリューオブジェクト
関数の引数が複数あると、順序を間違えてしまいます。スカラー型宣言では直せません。
すべての引数が異なるスカラー型なら、PHPは引数の順序で間違いを見つけて警告します。しかし、引数に同じ型がいくつが含まれている場合がほとんどです。
対処方法は、引数をバリューオブジェクトにラップします。
class UserId {
private $userId;
public function __construct(int $userId) {
$this->userId = $userId;
}
public function getValue() : int
{
return $this->userId;
}
}
class Subject {
private $subject;
public function __construct(string $subject) {
$this->subject = $subject;
}
public function getValue() : string
{
return $this->subject;
}
}
class Message {
private $message;
public function __construct(string $message) {
$this->message = $message;
}
public function getMessage() : string
{
return $this->message;
}
}
class Notification {
/* ... */
public function __construct(
UserId $userId,
Subject $subject,
Message $message
) {
$this->userId = $userId;
$this->subject = $subject;
$this->message = $message;
}
public function getUserId() : UserId { /* ... */ }
public function getSubject() : Subject { /* ... */ }
public function getMessage() : Message { /* ... */ }
}
引数に固有の型を持たせて、取り違えることがなくなりました。
バリューオブジェクトをスカラー型宣言と組み合わせて使う利点は、すべてのファイルでstrict_typesを有効にする必要がなくなります。これでうっかり忘れることもありません。
バリデーション
バリューオブジェクトを扱うときにバリデーションロジックをオブジェクトに内包します。将来アプリケーションが違うレイヤーで問題になる恐れのある「無効な状態」のバリューオブジェクトの作成を防止できます。
たとえば、UserIdは常に正の数でなければならないというルールがある時、UserIdが値を受け取る度にルールを検証すれば良いのですが、なんらかの理由でルールを忘れる可能性もあるのです。
アプリケーションの別のレイヤーでエラーになると、エラーメッセージに問題点が明確に示されず、デバッグが困難になります。
UserIdのコンストラクターにバリデーションを追加することで、このミスを予防できます。
class UserId {
private $userId;
public function __construct($userId) {
if (!is_int($userId) || $userId < 0) {
throw new \InvalidArgumentException(
'UserId should be a positive integer.'
);
}
$this->userId = $userId;
}
public function getValue() : int
{
return $this->userId;
}
}
UserIdオブジェクトを使う時に、オブジェクトが有効な状態だと保証できます。また、アプリケーションのさまざまレイヤーで何度もデータを検証する必要がなくなります。
is_intの代わりにスカラー型宣言を追加しても良いですが、その場合はUserIdを使うすべての場所でstrict_typesを有効にします。
strict_typesを有効にしないと、PHPはUserIdに渡されるすべてをint型に自動変換します。ユーザーIDはfloat型ではないので、この変数にfloat型を注入するのは誤りなのにできてしまったり、Priceバリューオブジェクトを使う際にstrict_typesを無効にすると、PHPがfloat型の変数をint型に自動変換することで丸め誤差が生じる恐れがあります。
不変性
PHPのデフォルト設定では、オブジェクトに変更を加えるとその変更がアプリケーション全体に瞬時に伝わります。
この手法には長所と短所があります。
NotificationをメールとSMSでユーザーに送信する例です。
interface NotificationSenderInterface
{
public function send(Notification $notification);
}
class SMSNotificationSender implements NotificationSenderInterface
{
public function send(Notification $notification) {
$this->cutNotificationLength($notification);
// Send an SMS...
}
/**
* Makes sure the notification does not exceed the length of an SMS.
*/
private function cutNotificationLength(Notification $notification)
{
$message = $notification->getMessage();
$messageString = substr($message->getValue(), 160);
$notification->setMessage(new Message($messageString));
}
}
class EmailNotificationSender implements NotificationSenderInterface
{
public function send(Notification $notification) {
// Send an e-mail ...
}
}
$smsNotificationSender = new SMSNotificationSender();
$emailNotificationSender = new EmailNotificationSender();
$notification = new Notification(
new UserId(17466),
new Subject('Demo notification'),
new Message('Very long message ... over 160 characters.')
);
$smsNotificationSender->send($notification);
$emailNotificationSender->send($notification);
Notificationを参照渡ししたため、副作用が生じます。SMSNotificationSenderのメッセージを短くすることで、参照元のNotificationオブジェクトがアプリケーション全体で更新され、EmailNotificationSenderからNotificationオブジェクトに送信したときも、メッセージが短くなってしまいました。
Notificationオブジェクトをイミュータブルにすることで対処できます。オブジェクトを変更するためのsetメソッドではなく、オブジェクトを変更する前に元のNotificationをコピーするwithメソッドを追加します。
class Notification {
public function __construct( ... ) { /* ... */ }
public function getUserId() : UserId { /* ... */ }
public function withUserId(UserId $userId) : Notification {
$c = clone $this;
$c->userId = clone $userId;
return $c;
}
public function getSubject() : Subject { /* ... */ }
public function withSubject(Subject $subject) : Notification {
$c = clone $this;
$c->subject = clone $subject;
return $c;
}
public function getMessage() : Message { /* ... */ }
public function withMessage(Message $message) : Notification {
$c = clone $this;
$c->message = clone $message;
return $c;
}
}
メッセージを短くするなど、変更をNotificationクラスに加えても、アプリケーション全体に波及せず、意図しない副作用を防止できます。
ただし、PHPで真にイミュータブルなオブジェクトを作るのは、無理ではないにしても難しいことです。それでも、コードをミスから守るという点では、「イミュータブル」なwithメソッドをsetメソッドの代わりに追加することで、オブジェクトを変更する前にクローンすることをクラスのユーザーが意識する必要がなくなります。
Nullオブジェクトを返す
値やnullを返す関数やメソッドがあります。戻り値がnullだと問題を引き起こす恐れがあります。そのため、戻り値を使う前に値がnullか確認するのですが、簡単に忘れがちです。そのため、nullオブジェクトを返せば、戻り値の確認は不要です。
割引が適用される、適用されないと、2つの可能性を持つShoppingCartがあるとします。
interface Discount {
public function applyTo(int $total);
}
interface ShoppingCart {
public function calculateTotal() : int;
public function getDiscount() : ?Discount;
}
ShoppingCartの価格を計算するとき、applyToメソッドを呼び出す前にgetDiscount()の返す値がnullかDiscountかを必ず確認します。
$total = $shoppingCart->calculateTotal();
if ($shoppingCart->getDiscount()) {
$total = $shoppingCart->getDiscount()->applyTo($total);
}
チェックを忘れると、getDiscount()がnullを返したときにPHPによる警告や予想外の影響が生じる可能性があります。
そこで、Discountがセットされていないときにnullオブジェクトを返せれば、チェックが不要になるのです。
class ShoppingCart {
public function getDiscount() : Discount {
return !is_null($this->discount) ? $this->discount : new NoDiscount();
}
}
class NoDiscount implements Discount {
public function applyTo(int $total) {
return $total;
}
}
getDiscount()を呼び出すときは、割引がなくてもDiscountオブジェクトを取得します。これで、割引がなくても合計に割引処理を適用します。if文が不要になりました。
$total = $shoppingCart->calculateTotal();
$totalWithDiscountApplied = $shoppingCart->getDiscount()->applyTo($total);
オプションの依存オブジェクト
nullの可能性がある戻り値を避けるのと同じ理由で、依存オブジェクトはオプションではなく、必須にすべきです。
以下のクラスを例に説明します。
class SomeService implements LoggerAwareInterface {
public function setLogger(LoggerInterface $logger) { /* ... */ }
public function doSomething() {
if ($this->logger) {
$this->logger->debug('...');
}
// do something
if ($this->logger) {
$this->logger->warning('...');
}
// etc...
}
}
この手法には2つの問題点があります。
- doSomething()メソッドで、loggerの存在を確認しなければならない
- サービスコンテナ内にSomeServiceクラスをセットアップするときに、利用者がloggerをセットし忘れる可能性がある。また、利用者がこのクラスにloggerをセットするオプションがあることを知らない可能性がある
LoggerInterfaceを必須の依存オブジェクトにすることでコードを単純化できます。
class SomeService {
public function __construct(LoggerInterface $logger) { /* ... */ }
public function doSomething() {
$this->logger->debug('...');
// do something
$this->logger->warning('...');
// etc...
}
}
これで、パブリックインターフェイスが簡潔になり、SomeServiceのインスタンスを新規作成するときにLoggerInterfaceのインスタンスが必須なので、インスタンスの注入を忘れません。
さらに、if文でloggerが注入された確認する必要がなくなったので、doSomething()が読みやすくなり、メソッドを変更する際のミスが減ります。
loggerなしでSomeServiceを使うなら、return文を追加してnullオブジェクトを使うと同じロジックを活用できます。
$service = new SomeService(new NullLogger());
結果的に、setLogger()メソッドをオプションで使うときと同じですが、コードがわかりやすいため、DIコンテナ内のミスを減らせます。
パブリックインターフェイス
コードの使いやすさの向上は、クラス内のpublicメソッドの量を最小限に抑えることが重要です。コードの使用法がわかりやすくなり、メンテナンスするコードの量が小さくなるので、リファクタリングの際に後方互換性を壊す可能性が減ります。
publicメソッドの量を最小限に抑えるには、publicメソッドをトランザクションと見なします。
2つの銀行口座間で送金する例で考えます。
$account1->withdraw(100);
$account2->deposit(100);
連動するデータベースに、入金がなければ出金できないように、または出金がないかぎり入金できないようになっている可能性はありますが、$account1->withdraw()か$account2->deposit()どちらかの呼び出しの制御は、データベースにはできません。そのため間違った残高になる恐れがあります。
幸い、2つの分離したメソッドを1つのトランザクションメソッドに置き換えることで、簡単に解決できます。
$account1->transfer(100, $account2);
トランザクションを部分的に完了させてミスが発生しなくなったため、コードが堅牢になりました。
ミス検出の例
ミス検出の仕組みはミス予防とは正反対で、エラーが起きるのを防ぐのではなく、問題を発見して警告します。
ミス検出の仕組みはアプリケーションの外にあり、定期的、または特定の変更があったときにコードを監視します。
単体テスト
単体テストはコードが正しく動作することを確認するための優れた方法です。システムの一部をリファクタリングしたときに、既存のコードが意図したとおりに動くことを確認する際にも役立ちます。
単体テストを忘れる可能性も否定できないので、Travis CIやGitlab CIなどのサービスを利用して、コードを変更した際テストを自動で実行することをおすすめします。自動でテストすれば、アプリケーションを壊してもすぐに把握できます。単体テストは、プルリクエストのレビューで変更箇所が意図したとおりに動くことを確認する際にも役立ちます。
単体テストはミス検出の方法ですが、コードがどんな動作を意図して作られたのか例証する方法でもあります。単体テストにはコード利用者のミスを予防する効果もあります。
コードカバレッジレポート、ミューテーションテスト
十分なテストを実施せず、忘れてしまうのは日常茶飯事です。単体テストでも、コードカバレッジレポートを自動生成するCoverallsのようなサービスを利用すると便利です。Coverallsはコード網羅率が低下したことを通知し、単体テストの追加を促します。Coverallsを利用すればコード網羅率の推移も把握できるのです。
コードに対し単体テストが十分に実施されていることを確認するために、Humbugでミューテーション(変異)テストを実施します。このテストではソースコードを若干変更してから単体テストを実行し、変異によって関連するテストに失敗するのを確認することで、コード網羅率が十分か検証します。
Using both code coverage reports and mutation tests, we can make sure that our unit tests cover enough code to prevent accidental mistakes or bugs.
コードカバレッジレポートとミューテーションテストを併用することで、単体テストのコード網羅率が偶発的なミスやバグを防止するのに十分であることを確認できます。
コードアナライザー
コードアナライザーなら、開発プロセスの早い段階でアプリケーションに潜むバグを検出できます。たとえば、IDEはコードアナライザーのPHPStormは、コードを書くときにエラーを警告したり助言したりします。対象は単純な構文エラーから重複コードの検出まで多岐にわたります。
コードアナライザーはほとんどのIDEに標準装備されていますが、特定の問題を検出するためのサードパーティーや独自のコードアナライザーをアプリケーション構築プロセスに組み込めます。PHPプロジェクトに最適なコードアナライザーの一覧がexakat/php-static-analysis-toolsにあります。コードアナライザーをすべて網羅しているわけではありませんが、コーディング規約を解析するものや、セキュリティの脆弱性を解析するものなど幅広い種類のコードアナライザーを見つけられます。
SensioLabs Insightsなどのオンラインソリューションもあります。
ログメッセージ
ほかのミス検出とは違い、ログメッセージは本番環境でアプリケーションのミスを検出します。
予期せぬ事態が発生したときにコードがメッセージを記録することが前提です。コードがロガーをサポートしていても、セットアップする中でロガーの設定を忘れる可能性があります。依存オブジェクトをオプションにすることは避けるべきです(上記参照)。
ほとんどのアプリケーションは少なくとも一部のメッセージを記録しています。その情報はKibanaやNagiosなどのツールを利用して能動的に解析、監視したときはじめて、本当に興味深いものになります。こうしたツールで、ユーザーが実際にアプリケーションを使用したときに発生する、内部的なテストで発生したものとは異なるエラーや警告をの新たな洞察を得られます。ELKスタックを用いたPHPアプリの監視についての優れた投稿がここにあります。
エラーを抑止しない
エラーメッセージを記録しているときに、一部のエラーが抑止されていることはよくあります。PHPは基本的に、「リカバリー可能な」エラーが発生したときは動作を継続し、アプリケーションを実行します。エラーはコード内に潜むバグの存在を知らせて、新たな機能を開発、テストする際に役立ちます。
@でエラーを抑制することでバグを見逃してしまうため、コードアナライザーは警告を出します。見逃したバグは、ユーザーがアプリケーションを使うときに必ず発生します。
通常はPHPのerror_reportingレベルをE_ALLに設定して、軽微な警告も出します。エラーメッセージはユーザーから見えない場所に記録して、アプリケーションのアーキテクチャーやセキュリティの潜在的脆弱性など、扱いに注意を要する情報をエンドユーザーに表示しない設定にします。
error_reportingの設定と同様、strict_typesを有効にしてPHPが関数の引数の型を自動変換しないようにします。ある型を別の型に変換すると、検出困難なバグが作り込まれることがあります(たとえば、floatをintにキャストする際に発生する丸め誤差など)。
PHP以外での使用例
ポカヨケは具体的な手法というよりも概念に近いため、PHPに関連したPHP以外の分野にも応用できます。
インフラストラクチャー
インフラストラクチャーレベルでは、Vagrantなどのツールを利用して本番環境と同一の開発環境を用意することで、ミスを大きく減らします。
また、JenkinsやGoCDなどのビルドサービスでデプロイ処理を自動化して、変更したアプリケーションをデプロイする際のミスを減らします。デプロイ処理はアプリケーションごとに固有の忘れてしまう可能性の高いさまざまな手順が含まれています。
REST API
REST APIの構築にもポカヨケを取り入れることでAPIの利便性が向上します。たとえば、URLクエリやリクエストボディに不明なパラメーターが渡ったらエラーを返します。APIのクライアントを「壊す」ことは避けたいのに、エラーを返すのは不自然に思えるかもしれませんが、APIが間違った形で使われたらできるだけ早く警告を出して、開発プロセスの早い段階でバグを修復したほうが良い結果につながります。
APIにcolorパラメーターがあり、APIの利用者が間違ってcolourパラメーターを使ったとします。警告がないと、ミスに気づかず公開し、エンドユーザーが予期しない動作を目にしてはじめてミスが見つかるという可能性が十分に考えられます。
アプリケーションの設定
どんなアプリケーションでも、個別設定は必要です。アプリケーションの設定をしなくて済むように、できる限りデフォルト設定値を採用する開発者もいます。
しかし、colorとcolourの例のように、設定パラメータをタイプミスして、結果アプリケーションが意図せずデフォルト値になることもあります。アプリケーションがエラーを通知しないと、ミスの発見は困難です。設定の誤りを見つける方法は、デフォルト値を一切用意せず、設定パラメーターが足りない場合は即座にエラーだと判定することです。
ユーザーのミスを防ぐ
ポカヨケの概念はユーザーのミスの予防や検出にも応用できます。支払い処理をするソフトウェアでユーザーが入力したアカウント番号をチェックディジットアルゴリズムで検証し、アカウント番号のタイプミスを防止します。
最後に
ポカヨケはツールではなく概念です。とはいえ、ポカヨケにはコードや開発プロセスに応用できる原則があり、活用することでミスの予防や早期発見につながります。フールプルーフなコードの実現にはアプリケーションやビジネスロジックごと個別の手段が必要ですが、一方でどんなコードにも使える単純な手法やツールもあります。
誰もが本番環境のエラーを避けたいですが、開発段階のエラーは有用なのです。エラーの顕在化を恐れてはいけません。エラーは早く簡単にミスを発見できます。エラー対策はコードでも、外部からアプリケーションを監視する、アプリケーションとは別に実行されるプロセスでも実現できます。
エラーを減らすには、コードのパブリックインターフェイスを単純でわかりやすくする必要があります。
参考文献
ポカヨケ
- ポカヨケ:トヨタ生産方式のガイドでは、トヨタの製造工程においてポカヨケがどのような目的で使われているか説明
- ポカヨケを用いてソフトウェアの品質を向上させる方法:ポカヨケを用いてソフトウェアの機能的品質を向上させるためのコツについて紹介
- コードをポカヨケする:ポカヨケをプログラミング全般に応用する方法を解説
- ポカヨケ ミス対策をソフトウェアに応用する:ポカヨケをプログラミングに応用する方法についてより詳しく解説
PHPにおけるポカヨケ
- 超防御的PHP:PHPコードのミスを防ぐ方法について
- イミュータブルなオブジェクトを使う3つの利点:イミュータブルなオブジェクトの利点について分かりやすく説明
- PHPにおけるイミュータブルなバリューオブジェクト:バリューオブジェクトを(できるかぎり)イミュータブルにする方法について簡単に紹介
- PHPとイミュータビリティ:PHPにおいてイミュータビリティがどのように役立つのか(そして役立たないのか)を詳しく解説
- 良いコードライティング コードの認知負荷を減らす方法:コードをわかりやすくして使用時や変更時にミスが起きないようにするためのさまざまな方法を紹介
本記事はDeji Akala、Marco Pivettaが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:Poka Yoke – Saving Projects with Hyper-Defensive Programming)
[翻訳:薮田佳佑/編集:Livit]