📝

CakePHP の Shell と Command どっちが優先されるの?

2021/11/28に公開

CakePHP では下記のように、CakePHP3.6.0 から Shell が非推奨となり、 Command の利用が推奨されるようになりました。

バージョン 3.6.0 で非推奨: Shell は 3.6.0 で非推奨ですが、 5.x までは削除されません。 代わりに コンソールコマンド を使用してください。

ただ、Shell も Command も実行コマンドは同じで、例えば HelloShell.php または HelloCommand.php を実行する際はどちらもbin/cake hello で実行できるとドキュメントには書いてあります。
基本的には Command を使えば良いので、これはこれで良さそうですが、実際移行する場合などは同じ名前の Shell と Command が共存するケースが存在するんじゃないかと思い、その場合はどうなるのかと気になりました。

本エントリでは、CakePHP の内部実装を見て、その疑問を解き明かそうと思います。

結論

コンソールコマンドが優先される(推奨されてるので当たり前っちゃ当たり前かも)

どうやって Command が優先されるのか

結論としてはコンソールコマンドが優先されるのですが、実際内部ではどのように動いてるのでしょうか?
ちょっと深ぼって見てみます。

まずは CommandRunner::run() → CommandCollection::autoDiscover() が実行

Shell や Command を実行すると\Cake\Console\CommandRunner::runが実行されます。そこから\Cake\Console\CommandCollection::autoDiscoverが実行されます。autoDiscover()は下記のようになっています。

/**                                                                                  
 * Automatically discover shell commands in CakePHP, the application and all plugins.
 *                                                                                   
 * Commands will be located using filesystem conventions. Commands are               
 * discovered in the following order:                                                
 *                                                                                   
 * - CakePHP provided commands                                                       
 * - Application commands                                                            
 *                                                                                   
 * Commands defined in the application will overwrite commands with                  
 * the same name provided by CakePHP.                                                
 *                                                                                   
 * @return string[] An array of command names and their classes.                     
 */                                                                                  
public function autoDiscover(): array                                                
{                                                                                    
    $scanner = new CommandScanner();                                                 
                                                                                     
    $core = $this->resolveNames($scanner->scanCore());                               
    $app = $this->resolveNames($scanner->scanApp());                                 
                                                                                     
    return array_merge($core, $app);                                                 
}                                                                                    

PHP Doc にあるように core というCakePHP標準のコマンドと app というアプリケーション側のコマンドを見つけて、がっちゃんこしてくれるっぽいですね。
今回はアプリケーション側 に着目してみます。 $app = $this->resolveNames($scanner->scanApp()); では何が行われているのか見てみましょう。

scanApp() で shell と command を取得する

/**                                           
 * Scan the application for shells & commands.
 *                                            
 * @return array A list of command metadata.  
 */                                           
public function scanApp(): array                     
{                                                    
    $appNamespace = Configure::read('App.namespace');
    $appShells = $this->scanDir(                       
        App::classPath('Shell')[0],                  
        $appNamespace . '\Shell\\',                  
        '',                                          
        []                                           
    );                                               
    $appCommands = $this->scanDir(                     
        App::classPath('Command')[0],                
        $appNamespace . '\Command\\',                 
        '',                                          
        []                                           
    );                                               
                                                     
    return array_merge($appShells, $appCommands);    
}                                                    

\Cake\Console\CommandScanner::scanAppではPHP Docでアプリケーションの Shell と Command をスキャンする、と書いてます。実装を見ると scanDir() というメソッドを使って Shell ディレクトリ配下と Command ディレクトリ配下のファイル名を取得しています。例えば、$appCommands の場合、下記のような配列が返ってくるようです。

Array
(
    [0] => Array
        (
            [file] => /var/www/html/src/Command/HelloCommand.php
            [fullName] => hello
            [name] => hello
            [class] => App\Command\HelloCommand
        )
)

そして、これを最終的にarray_merge() してがっちゃんこしてますね。なので scanApp() をすると下記のような配列が出来上がります。

Array
(
    [0] => Array
        (
            [file] => /var/www/html/src/Shell/HelloShell.php
            [fullName] => hello
            [name] => hello
            [class] => App\Shell\HelloShell
        )
    [1] => Array
         (
            [file] => /var/www/html/src/Command/HelloCommand.php
            [fullName] => hello
            [name] => hello
            [class] => App\Command\HelloCommand
        )

)

この配列を resolveNames() するとどうなるでしょうか。

resolveNames() で shell と command を整理

/**                                                              
 * Resolve names based on existing commands                      
 *                                                               
 * @param array $input The results of a CommandScanner operation.
 * @return string[] A flat map of command names => class names.  
 */                                                              
protected function resolveNames(array $input): array             
{                                                                
    $out = [];                                                   
    foreach ($input as $info) {                                  
        $name = $info['name'];                                   
        $addLong = $name !== $info['fullName'];                  
                                                                 
        // If the short name has been used, use the full name.   
        // This allows app shells to have name preference.       
        // and app shells to overwrite core shells.              
        if ($this->has($name) && $addLong) {                     
            $name = $info['fullName'];                           
        }                                                        
                                                                 
        $out[$name] = $info['class'];                            
        if ($addLong) {                                          
            $out[$info['fullName']] = $info['class'];            
        }                                                        
    }                                                            
                                                                 
    return $out;                                                 
}                                                                

$inputに先程の配列が入ります。この $input が foreach で色々処理されますが、最終的に
$out[$name] = $info['class']; をして $out$name をキー、 $info['class'] を値として入れられます。 scanApp() で返ってきた配列を見ると、キーである name や fullName は同じ名前のため、後続で処理されたものが上書きされます。

Array
(
    [hello] => Cake\Command\HelloCommand
)

なので、$out には結果として上記のようになり、Shell と Command が共存する場合、Command が生き残るわけですね。

整理したshellやcommandを実行!

上記のような形で、CakePHP標準コマンドやアプリケーション側のコマンドが処理され、\Cake\Console\CommandRunner::run 内の下記で、$shellが \Cake\Console\Shell のインスタンスなら runShell()でシェルとして実行、\Cake\Console\CommandInterface のインスタンスなら runCommand() でコマンドとして実行される、という処理になっています。

$shell = $this->getCommand($io, $commands, $name);  
if ($shell instanceof Shell) {                      
    $result = $this->runShell($shell, $argv);       
}                                                   
if ($shell instanceof CommandInterface) {           
    $result = $this->runCommand($shell, $argv, $io);
}        

まとめ

今回は CakePHP の機能である Shell と Command に関する疑問について、内部実装を追ってみました。
よしなにやってくれるフレームワークですが、内部まで見てみると納得感もありますし、根拠を持って実装できますね。

Discussion