もっとTL; DR
LiveWireではすでに対応済み。この記事は気にするな!
Fix SupportFileDownloads - Multibyte character support - GitHub Livewire
TL; DR;
- Livewire v2のファイルダンロード処理は
filename*
に対応していない。 -
Livewire\Features\SupportFileDownloads
をカスタマイズすれば対応できる -
Livewire\Features\SupportFileDownloads
カスタマイズを適用するためにはLivewire\LivewireManager
をハックする必要あり。 - 大人しく今後のバージョンアップでの改修を待ったほうがいいかも
バージョン
FW / MW | バージョン |
---|---|
laravel/framework | 10.1 |
livewire/livewire | 2.12 |
問題
Livewire公式にあるコードをもじって、日本語のファイル名をダウンロードする。
class ExportButton extends Component
{
public function export()
{
return Storage::disk('exports')->download('結果.csv');
}
}
このとき、ダウンロードされるファイル名はどうなるか示せ。
答え
__.csv
なぜ?
からくりは、レスポンス→JSONへの変換処理でした
Laravelがダウンロードレスポンスを生成する際は正しいレスポンスを生成しています。
上記コードとしては、レスポンスヘッダは以下のような形となります。
Content-Disposition attachment; filename=__.csv; filename*=utf-8''%E7%B5%90%E6%9E%9C.csv
filename属性はASCIIのみの対応であるため、日本語ファイル(というよりもマルチバイト文字)の場合、filename*属性にファイル名を定義します。
また、 filename*属性とfilename属性が設定されている場合、filename*属性が優先されなければならない 仕様です。
さて、Laravelが返却しているレスポンスをLivewireはJSONのレスポンスとして加工します。
これはフロントエンド側のAlpine.js
と通信するための処理なのですが、ここでやらかしています。
以下のコードはファイルダウンロードを制御するLaravelのソースコードです。
<?php
namespace Livewire\Features;
use Illuminate\Contracts\Support\Responsable;
use Livewire\Livewire;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class SupportFileDownloads
{
static function init() { return new static; }
protected $downloadsById = [];
function __construct()
{
Livewire::listen('action.returned', function ($component, $action, $returned) {
if($returned instanceof Responsable){
$returned = $returned->toResponse(request());
}
if ($this->valueIsntAFileResponse($returned)) return;
$response = $returned;
$name = $this->getFilenameFromContentDispositionHeader(
$response->headers->get('Content-Disposition')
);
$binary = $this->captureOutput(function () use ($response) {
$response->sendContent();
});
$content = base64_encode($binary);
$this->downloadsById[$component->id] = [
'name' => $name,
'content' => $content,
'contentType' => $response->headers->get('Content-Type'),
];
});
Livewire::listen('component.dehydrate.subsequent', function ($component, $response) {
if (! $download = $this->downloadsById[$component->id] ?? false) return;
$response->effects['download'] = $download;
});
Livewire::listen('flush-state', function() {
$this->downloadsById = [];
});
}
function valueIsntAFileResponse($value)
{
return ! $value instanceof StreamedResponse
&& ! $value instanceof BinaryFileResponse;
}
function captureOutput($callback)
{
ob_start();
$callback();
return ob_get_clean();
}
function getFilenameFromContentDispositionHeader($header)
{
/**
* The following conditionals are here to allow for quoted and
* non quoted filenames in the Content-Disposition header.
*
* Both of these values should return the correct filename without quotes.
*
* Content-Disposition: attachment; filename=filename.jpg
* Content-Disposition: attachment; filename="test file.jpg"
*/
if (preg_match('/.*?filename="(.+?)"/', $header, $matches)) {
return $matches[1];
}
if (preg_match('/.*?filename=([^; ]+)/', $header, $matches)) {
return $matches[1];
}
return 'download';
}
}
注目したいのがgetFilenameFromContentDispositionHeader
メソッドです。ここではfilename
属性のみしか参照していません。つまり、どれだけLaravel上の処理で日本語ファイルを設定したところで、filename*
属性が無視されるため、日本語ファイル名にならないわけです。
日本語ファイルをダウンロードできるようにするために。
日本語のファイルをダウンロードできるようにするため、Livewireの わりかしコアな部分 をカスタマイズします。
SupportFileDownloadsをハックする
SupportFileDownloads
クラスを拡張して、filename*
を考慮するようにします。
getFilenameFromContentDispositionHeader
をオーバーライドする形で完了です。
<?php
namespace App\Livewire\Features;
use Livewire\Features\SupportFileDownloads as LivewireSupportFileDownloads;
use illuminate\support\Str;
/**
* Livewire上でのファイルダウンロード処理カスタマイズ
*
* 標準のLivewireでは考慮してくれないfilename*属性をカバーする実装
*/
class SupportFileDownloads extends LivewireSupportFileDownloads
{
/**
* @override
* @inheritDoc
*/
public function getFilenameFromContentDispositionHeader($header)
{
$rfc5987Filename = match (true) {
preg_match('/.*?filename\*=([^; ]+)/', $header, $matches) === 1 => $matches[1],
preg_match('/.*?filename\*="(.+?)"/', $header, $matches) === 1 => $matches[1],
default => null,
};
// filename*属性がなければ元の処理を使用する
if (is_null($rfc5987Filename)) {
return parent::getFilenameFromContentDispositionHeader($header);
}
// エンコード・未デコード文字列を分割
// ファイル名をデコードして返却
$filename = Str::after($rfc5987Filename, '\'\'');
return rawurldecode($filename);
}
}
LivewireManager / Livewireファサードをハックする
Livewireのコア処理はイベント駆動で動いています。
SupportFileDownloads
クラスのLivewire::listen('action.returned', function ($component, $action, $returned) { ... })
のところがまさにイベントを定義するところですね。
ただ、このイベント駆動、追加しかできません。リスナーを置き換えたり削除したりすることができないようになっています。そりゃそうだ、フレームワークのコアに関わる部分だから。
しかし、日本語ファイルのダウンロードを行うにあたって、action.returned
イベントに元のSupportFileDownloads
クラスが設定するリスナーがあると不都合があります。
問題は以下のコード。
$binary = $this->captureOutput(function () use ($response) {
$response->sendContent();
});
ファイルデータをバイナリで取得するため、$response->sendContent()
を実行しています。
このメソッドが実行されるとコンテンツがすべて流れてしまうため、以降のリスナーでコンテンツが取得できなくなります(もしかしたらシーク的なことをすれば取れるのかもしれないですが)。
なので、イベントリスナーを弄らないといけないわけで。
イベントを管理するのはLivewire\LivewireManager
、インターフェースとしてはLivewire\Livewire
ファサードです。
以下はlivewireが定義するLivewireファサードです。
<?php
namespace Livewire;
use Illuminate\Support\Facades\Facade;
/**
* @method static void component($alias, $viewClass)
* @method static \Livewire\Testing\TestableLivewire test($name, $params = [])
* @method static \Livewire\LivewireManager actingAs($user, $driver = null)
* @method static \Livewire\LivewireManager withQueryParams($queryParams)
*
* @see \Livewire\LivewireManager
*/
class Livewire extends Facade
{
public static function getFacadeAccessor()
{
return 'livewire';
}
}
livewire
というエイリアスで依存性注入をしているので、カスタマイズ版LivewireManagerを上書きで注入すれば 乗っ取ることができそう です。
以下 LivewireManagerのカスタマイズです。
<?php
namespace App\Livewire;
use Livewire\LivewireManager as BaseLivewireManager;
/**
* LivewireManagerのカスタマイズ
*/
class LivewireManager extends BaseLivewireManager
{
/**
* Livewireのイベントリスナをリセットする
*
* @return void
*/
public function resetListener()
{
$this->listeners = [];
}
}
LievewireManagerに対してイベントリスナをリセットする処理を実装します。内部的に$listeners
プロパティがイベントリスナを保持しているため、これを初期化します。
また、特にイジる必要もないかもしれませんが、ドキュメントコメント的にresetListener
メソッドが表示されない気がするので、独自のLivewireファサードを定義します。ただし、Livewireが持っているLivewire
ファサードと独自のLivewireファサードは同じLivewireManagerインスタンス(乗っ取り版)を参照します。
<?php
namespace App\Livewire;
use Illuminate\Support\Facades\Facade;
/**
* @method static void component($alias, $viewClass)
* @method static \Livewire\Testing\TestableLivewire test($name, $params = [])
* @method static \Livewire\LivewireManager actingAs($user, $driver = null)
* @method static \Livewire\LivewireManager withQueryParams($queryParams)
* @method static void resetListener()
*
* @see \App\Livewire\Livewire\LivewireManager
*/
class Livewire extends Facade
{
public static function getFacadeAccessor()
{
return 'livewire';
}
}
サービスプロバイダでLivewireを乗っ取る
さて、Livewireで日本語ファイルを扱うための部品を実装しました。
最後、残っているタスクは以下の2つです。
- DIとして
livewire
エイリアスにLivewireManager(乗っ取り版)を設定する - Livewireの初期化として、カスタマイズ版
SupportFileDownloads
を利用するようにする。
Livewire本体が持っているサービスプロバイダの書き方に寄せて以下のように実装してみます。
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Livewire\Features\SupportFileDownloads;
use App\Livewire\Livewire;
use App\Livewire\LivewireManager;
use Livewire\Features\OptimizeRenderedDom;
use Livewire\Features\SupportActionReturns;
use Livewire\Features\SupportBootMethod;
use Livewire\Features\SupportBrowserHistory;
use Livewire\Features\SupportChildren;
use Livewire\Features\SupportComponentTraits;
use Livewire\Features\SupportEvents;
use Livewire\Features\SupportFileUploads;
use Livewire\Features\SupportLocales;
use Livewire\Features\SupportPostDeploymentInvalidation;
use Livewire\Features\SupportRedirects;
use Livewire\Features\SupportRootElementTracking;
use Livewire\Features\SupportStacks;
use Livewire\Features\SupportValidation;
/**
* LivewireServiceProviderカスタマイズプロバイダ
*/
class LivewireCustomServiceProvider extends ServiceProvider
{
/**
* サービスの初期登録処理
*
* @return void
*/
public function register()
{
$this->registerLivewireSingleton();
}
/**
* サービスの初期起動処理
*
* @return void
*/
public function boot(): void
{
$this->registerFeatures();
}
/**
* @override
* @inheritDoc
*/
protected function registerLivewireSingleton()
{
// LivewireManagerをカスタマイズ版に切り替えて登録する
$this->app->singleton(LivewireManager::class);
$this->app->alias(LivewireManager::class, 'livewire');
}
/**
* @override
* @inheritDoc
*/
protected function registerFeatures()
{
// イベントリスナをすべてリセット
Livewire::resetListener();
// 元のLivewireServiceProviderで行っていたイベントリスナ登録処理を実行
// NOTICE: Livewireの更新に合わせて変更をする必要あり
SupportEvents::init();
SupportStacks::init();
SupportLocales::init();
SupportChildren::init();
SupportRedirects::init();
SupportValidation::init();
SupportBootMethod::init();
SupportFileUploads::init();
OptimizeRenderedDom::init();
// ただしファイルダウンロードだけはカスタマイズ版イベントリスナを登録
// Features\SupportFileDownloads::init();
SupportFileDownloads::init();
SupportActionReturns::init();
SupportBrowserHistory::init();
SupportComponentTraits::init();
SupportRootElementTracking::init();
SupportPostDeploymentInvalidation::init();
}
}
registerLivewireSingleton
メソッドでLivewireManager及びLivewireファサードを乗っ取ります。
そしてregisterFeatures
メソッドにてLivewireManager(乗っ取り版)にLivewireのイベントリスナーを登録していきます。もちろんファイルダウンロードについてはカスタマイズ版のリスナー版に切り替えます。
Support***::init()
メソッドですが、内部的にはLivewire::listen
メソッドでイベントリスナー登録を行います。
registerLivewireSingleton
メソッドでファサードを乗っ取っているため、Livewireそのものが持つLivewireManagerではなくLivewireManager(乗っ取り版)にイベントリスナーが登録されます。
ここまでしてようやく、Livewire上で日本語ファイル名のダウンロードが機能するようになります。
乗っ取っておいてなんだけれど……
さて、目的の日本語ファイル名でダウンロードができましたが。
素直に今後のバージョンアップでの改修を待ったほうがよいかもしれないです。
乗っ取る という言葉を使っているのはわざとで、Livewireのコア処理を書き換える試みなので、今後のバージョンアップの影響をもろに受けます。
特にSupportFileDownloads
クラスとサービスプロバイダのregisterFeatures
メソッドは丁寧にリリースノートを追跡しないといつの間に破壊的変更が……なんてことになりかねません。
この事象、「マルチバイト文字を使用したファイル名でダウンロードしようとするとただしい名称にならない」という限りなく黒に近い不具合なので。
すでに先人がDiscussionを立てていますし。
File dowload response replaces greek characters from the filename with underscores
いつかこの記事が役に立たなくなることを願って。