Laravel 6での\Storage::disk('public')->url('')のリンク切れの対処

Laravel 6でpublic diskからファイルの参照時にリンクがあっていない問題に遭遇したので対処方法を記す。

導入

Laravelのファイルストレージについては「File Storage」で説明されている。大きくprivateとpublicがある。

デフォルトのprivateの保存場所は [storage/app/xxx] (xxxpublic以外のディレクトリー) となっており,これはユーザーから見えないため,img要素などのリンク先として指定できない。アプリケーション内でデータの中身を直接表示させる場合に使用する。

publicの保存場所は [storage/app/public/] となっている。php artisan storage:linkコマンドを実行すると,public/storageからstorage/app/publicにシンボリックリンクを貼ってくれるため,ユーザーからもアクセスできる。

このprivateとpublicの保存場所は,保存する場合は例えばstoreメソッドの第2引数やstoreAsメソッドの第3引数に'public'をつけることで,デフォルトのprivateからpublicに変更できる。

今回はpublicに保存した画像ファイルの参照時にリンク切れを起こす問題に遭遇した。

publicの参照方法

public領域に保存されたファイルは実際には,http://localhost/storage/以下のURLで参照される。

実際に参照する場合には2通りの方法がある。

  1. url('storage')
  2. \Storage::disk('public')->url('')

まず1点目は url('storage/') で参照する方法だ。urlのヘルパー関数を使えば,.envAPP_URLごとに異なるドメイン部分を自動的に解決してくれる。ただし,この方法だとstorageという固定文字列を指定する必要が出てくる。

実はこのstorageという文字列はconfig/filesystems.phppublic->urlプロパティで以下のように決め打ちで指定されている。

        'public' => [
            'driver' => 'local',
            'root' => storage_path('app/public'),
            'url' => env('APP_URL').'/storage',
            'visibility' => 'public',
        ],

こちらのstorageは,この設定ファイルを書き換えることで変更できる。しかし,参照時にstorageの文字列を直接書いてしまうと,config/filesystems.phppublic->url編集時にリンク切れを起こしてしまう。そのため,url('storage')での参照はしないほうがいい。

そこで2点目の方法となる。こちらは\Storage::disk('public')->url('')により,diskの種類を指定してからURLを参照している。これにより,storageの固定文字列を使わずに済み,config/filesystems.phppublic->urlを編集しても影響を受けない。そのため,ファイルの参照時は常にこの2番目の方法を使うべきだろう。

\Storage::disk('public')->url('')の問題

ただし,この方法にも問題がある。それは,ポート番号を反映できないという問題だ。Laravelのローカルの開発時では,Laravelの組み込みのWebサーバーをphp artisan serveで起動して動作を確認することが多い。この際,デフォルトのポート番号は8000となっており,アプリのURLはhttp://localhost:8000となる。

この状態での\Storage::disk('public')->url('')の返却値は,http://localhost/storageとなる。つまり,\Storage::disk('public')->url('')でポート番号が反映できておらず,リンク切れが発生する。

この理由は先程掲載したconfig/filesystems.phpにある。public->urlの値をみると,'url' => env('APP_URL').'/storage', となっている。つまり,APP_URLのドメインを参照している。肝心のAPP_URL.envで定義されており,デフォルトではAPP_URL=http://localhostとなっている。.envAPP_URLにポート番号がないのが原因となる。

ただ,urlヘルパー関数だと問題なくポート番号も解決できている。それではと思い,config/filesystems.phpenv('APP_URL').url('').に差し替えてみると,php artisanの任意のコマンドを実行するとエラーが出る。

In UrlGenerator.php line 120:
                                                                               
  Argument 2 passed to Illuminate\Routing\UrlGenerator::__construct() must be  
   an instance of Illuminate\Http\Request, null given, called in /home/senook  
  en/project/product/qing/vendor/laravel/framework/src/Illuminate/Routing/Rou  
  tingServiceProvider.php on line 68

調べたところ,Laravelのライフサイクルの都合でconfigファイルではurlヘルパー関数を使えないらしい (参考: Blog – Can’t use “url” helper function in config files in Laravel | BinBytes)。

対処方法

こうなると対策は以下の2通りとなる。

\Storage::disk('public')->url('') のリンク切れの対処方法
  1. .envAPP_URL=http://localhost:8000を指定。
  2. config/filesystems.phppublic->urlenv('APP_URL').'/storage',から'/storage',に変更。

ソースコードを修正したくなければ,1の方法,修正してもよければ2の方法で対応する。

1点目は素直に,.envAPP_URLにポート番号を指定するというものだ。これでローカルの開発環境は問題ない。ただ,これは開発環境をphp artisan servからApacheなど別の環境に移した場合に再設定が必要になる。ポート番号8000はあくまでphp artisan servのデフォルトだからそうしているだけだからだ。そのため,個人的にはこの方法はよい方法と思わない

2点目はconfig/filesystems.phpenv('APP_URL')を削除するというものだ。根本的な問題としては,ここでポート番号の存在しないAPP_URLを参照しているのが問題だ。そこで,諸悪の根源のenv('APP_URL')を削除する。こうすると,\Storage::disk('public')->url('')の返却値は/storageとなる。先頭のドメイン部分が消えたルートパス表記となる。

実際のところ,アプリケーションは同一サーバーで実行することになるので,絶対パス指定だろうルートパス指定だろうがリンク先に違いはない。リンク切れになるくらいならばこちらのほうがよっぽどいい

そもそもデフォルトでリンク切れになる挙動がおかしいと思ったので,Laravel本体にPull Requestを送ってみた ([6.x] Fix default disks public url for php artisan serve by senooken · Pull Request #5270 · laravel/laravel)。ただ,APP_URLで指定しろと短いコメントが返ってきただけで,たいした説明もなく,即座にクローズされてしまった。

しかも他の人から「👎」の反応までもらった。引き下がってコメントを追加しても何の返事もなく,印象が悪かった。他人のライブラリーを使っている自分が悪いのだが,Laravelに対する印象が悪くなるできごとだった。

ソースコード調査

このURLの生成について気になったのでメモを残す。

urlヘルパー関数

デフォルトのurlヘルパー関数の処理を確認する。

vendor/laravel/framework/src/Illuminate/Foundation/helpers.php
    function url($path = null, $parameters = [], $secure = null)
    {
        if (is_null($path)) {
            return app(UrlGenerator::class);
        }

        return app(UrlGenerator::class)->to($path, $parameters, $secure);
    }

UrlGenerator#toを経由している。

vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php
    public function formatRoot($scheme, $root = null)
    {
        if (is_null($root)) {
            if (is_null($this->cachedRoot)) {
                $this->cachedRoot = $this->forcedRoot ?: $this->request->root();
            }

            $root = $this->cachedRoot;
        }

        $start = Str::startsWith($root, 'http://') ? 'http://' : 'https://';

        return preg_replace('~'.$start.'~', $scheme, $root, 1);
    }

toの中でformatRootがあり,その中で$request->root()がある。ここでRequestのインスタンスからドメインを取得しているようだ。

\Storage::disk()->url()

Storage.diskメソッドを定義しているStorageクラスを確認する。

vendor/laravel/framework/src/Illuminate/Support/Facades/Storage.php
 * @see \Illuminate\Filesystem\FilesystemManager
 */
class Storage extends Facade
{

このファイル内にdiskメソッドの定義はなかったので,コメントにある通りFilesystemManagerを確認する。

vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemManager.php::disk
    /**
     * Get a filesystem instance.
     *
     * @param  string|null  $name
     * @return \Illuminate\Contracts\Filesystem\Filesystem
     */
    public function disk($name = null)
    {
        $name = $name ?: $this->getDefaultDriver();

        return $this->disks[$name] = $this->get($name);
    }

diskではFileSystemインスタンスを返却している。

続いて,\Storage::disk()->url()FileSystem#urlメソッドを追いかける。

vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemAdapter.php
    public function url($path)
    {
        $adapter = $this->driver->getAdapter();
...
        } elseif ($adapter instanceof LocalAdapter) {
            return $this->getLocalUrl($path);
        } else {

getLocalUrlメソッドを通っていた。

vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemAdapter.php
    protected function getLocalUrl($path)
    {
        $config = $this->driver->getConfig();

        // If an explicit base URL has been set on the disk configuration then we will use
        // it as the base URL instead of the default path. This allows the developer to
        // have full control over the base path for this filesystem's generated URLs.
        if ($config->has('url')) {
            return $this->concatPathToUrl($config->get('url'), $path);
        }

getConfigで取得したconfigからurlを参照している。

vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemManager.php
    /**
     * Get the filesystem connection configuration.
     *
     * @param  string  $name
     * @return array
     */
    protected function getConfig($name)
    {
        return $this->app['config']["filesystems.disks.{$name}"] ?: [];
    }

ここがconfig/fileysstems.phpを参照している。

config/fileysstems.php
        'public' => [
            'driver' => 'local',
            'root' => storage_path('app/public'),
            'url' => env('APP_URL').'/storage',
            'visibility' => 'public',
        ],

これで\Storage::disk('public')->url('')config/filesystems.phpを参照していることを特定できた。

結論

Laravel 6の\Storage::disk('public')->url('')によるpublic diskへのアクセス時のリンク切れの問題について解説した。

ソースコードを追いかけたりPull Requestを出したり一つ一つ時間がかかった。だが,その分理解が深まった。

public diskへの参照時にまたリンク切れが発生した場合に参考にしたい。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です