はじめに
発端は、Laravelでjson apiサーバを作っていて、jsonの中の日本語文字列がエスケープされていると読めなくてつらいなと思ったところから始まります。ちゃんと動くようになってしまえばクライアントがデコードしてしまうからエスケープされていても問題はないわけですが、それまでのデバッグ過程で通信内容をのぞいたりログをとったり手でアクセスしてみたりという機会はいくらでもありますからね。
ということで、JSON_UNESCAPED_UNICODEオプションを使いたいわけですがどうすれば指定できるか調べていきます。
どこでjson_encodeしているか
\Illuminate\Http\JsonResponse
クラス
まずjson応答といえば思いつくのがこのクラスです。
エンコード処理はsetData
関数の中にあります。
public function setData($data = [])
{
$this->original = $data;
if ($data instanceof Jsonable) {
$this->data = $data->toJson($this->encodingOptions);
} elseif ($data instanceof JsonSerializable) {
$this->data = json_encode($data->jsonSerialize(), $this->encodingOptions);
} elseif ($data instanceof Arrayable) {
$this->data = json_encode($data->toArray(), $this->encodingOptions);
} else {
$this->data = json_encode($data, $this->encodingOptions);
}
if (! $this->hasValidJson(json_last_error())) {
throw new InvalidArgumentException(json_last_error_msg());
}
return $this->update();
}
$this->encodingOptions
に保存したオプションを渡すようになっていて、コンストラクタのパラメタやsetEncodingOptions
メソッドで設定できます。
\Illuminate\Http\Response
クラス
Response
クラスもそういえばResponse::create()
で配列やモデルを渡すとjsonにしてくれますが、この処理は自前でもっていました。それがこのmorphToJson
メソッドです。
protected function morphToJson($content)
{
if ($content instanceof Jsonable) {
return $content->toJson();
} elseif ($content instanceof Arrayable) {
return json_encode($content->toArray());
}
return json_encode($content);
}
オプションの影すらありません。潔すぎます。とりあえずResponseに配列やモデルを渡すパターンは使わない方が良さそう...
JsonResponseをnewするところ
\Illuminate\Http\Response
のことはとりあえず使わなければいいので無視することにして、\Illuminate\Http\JsonResponse
が作られるときにコンストラクタの$optionsパラメタにJSON_UNESCAPED_UNICODEを設定する余地がないか調べていきます。
\Illuminate\Routing\ResponseFactory::json
Response
ファサードでResponse::json(...)
したときやresponse
ヘルパーで`response()->json(...)したときに呼ばれる奴です。
public function json($data = [], $status = 200, array $headers = [], $options = 0)
{
return new JsonResponse($data, $status, $headers, $options);
}
\Illuminate\Routing\Router::toResponse
コントローラの戻り値としてResponseクラスではなくいきなり配列とかモデルを返したときにいい感じにjson応答にする処理です。
改めて読んでみてはじめて知りましたが、EloquentモデルでwasRecentlyCreatedのときは201応答にするとか小技が効いてますね。
public static function toResponse($request, $response)
{
if ($response instanceof Responsable) {
$response = $response->toResponse($request);
}
if ($response instanceof PsrResponseInterface) {
$response = (new HttpFoundationFactory)->createResponse($response);
} elseif ($response instanceof Model && $response->wasRecentlyCreated) {
$response = new JsonResponse($response, 201);
} elseif (! $response instanceof SymfonyResponse &&
($response instanceof Arrayable ||
$response instanceof Jsonable ||
$response instanceof ArrayObject ||
$response instanceof JsonSerializable ||
is_array($response))) {
$response = new JsonResponse($response);
} elseif (! $response instanceof SymfonyResponse) {
$response = new Response($response);
}
if ($response->getStatusCode() === Response::HTTP_NOT_MODIFIED) {
$response->setNotModified();
}
return $response->prepare($request);
}
\Illuminate\Foundation\Exceptions\Handler::prepareJsonResponse
例外ハンドラで応答がjsonのときに使われる奴です。
protected function prepareJsonResponse($request, Exception $e)
{
return new JsonResponse(
$this->convertExceptionToArray($e),
$this->isHttpException($e) ? $e->getStatusCode() : 500,
$this->isHttpException($e) ? $e->getHeaders() : [],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
);
}
どう直すか
\Illuminate\Http\JsonResponse
や\Illuminate\Http\Response
を自分で直接呼んでいるところはどっちにしろ直さなければならないのであきらめます。コントローラでreturn new JsonResponse::create(...)
などとしているところはとりあえずいい機会なので修正することにします。
また、配列やモデルをコントローラから返して自動的にjson応答に変換させる機能は使わないことにします。
そうすると修正する必要があるのは、
-
\Illuminate\Routing\ResponseFactory
のjsonメソッド -
\Illuminate\Foundation\Exceptions\Handler
のprepareJsonResponseメソッド
の2つになります。
ResponseFactoryはDIコンテナで解決されるので、修正したクラスに入れ替えてやります。
例外ハンドラの方はもともとextendしたApp\Exceptions\Handler
を使うようになっているので、そちらで上書きしてやります。
App\Routing\MyResponseFactory
:
<?php
namespace App\Routing;
use Illuminate\Routing\ResponseFactory;
class MyResponseFactory extends ResponseFactory
{
public function json($data = [], $status = 200, array $headers = [], $options = JSON_UNESCAPED_UNICODE)
{
return parent::json($data, $status, $headers, $options);
}
}
bootstrap/app.php
に追加
$app->singleton(
ResponseFactoryContract::class,
function ($app) {
return new MyResponseFactory($app[ViewFactoryContract::class], $app['redirect']);
}
);
App\Execptions\Handler
に追加
protected function prepareJsonResponse($request, Exception $e)
{
return new JsonResponse(
$this->convertExceptionToArray($e),
$this->isHttpException($e) ? $e->getStatusCode() : 500,
$this->isHttpException($e) ? $e->getHeaders() : [],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
}
}
実行例
% curl -H 'accept: application/json' http://127.0.0.1:8000/api/foo\?p=%E3%81%82
{"foo":"日本語","p":"あ"}
% curl -H 'accept: application/json' http://127.0.0.1:8000/api/foo\?p=%E3%81%82%E3%81%84%E3%81%86
{
"message": "invalid p: あいう",
"exception": "RuntimeException",
(以下略)
おわりに
コントローラの戻り値をリターンするところで
return Response::json(...); // ファサードを使うパターン
または
return response()->json(...); // ヘルパーを使うパターン
という書き方をした場合という限定つきですが、普通のLaravelの書き方でJSON_UNESCAPED_UNICODEオプションを使うことができたのでよしとします。(というかLaravelいろんな書き方できすぎ)
でもできればLaravelの設定を一ついじるだけでいいようにしてほしいですね。
本件についてはPHPユーザーズ(日本語)slack(phpusers-ja.slack.com)のLaravelチャンネルの皆さんに相談に乗っていただきました。ありがとうございました。
lumenについての追記
lumenのresponse
ヘルパーはLaravel\Lumen\Http\ResponseFactory
がハードコードされているのでDIコンテナの登録を入れ替えるやり方では手が出せません。Response
ファサードはそもそもありません。(正確にはファサードのクラス自体は残ってますがalias定義もないし、Illuminate\Contracts\Routing\ResponseFactory
がコンテナに登録されてないので使えません)
多分responseヘルパーを置き換えるとかそんな感じになるかと。
if (! function_exists('response')) {
/**
* Return a new response from the application.
*
* @param string $content
* @param int $status
* @param array $headers
* @return \Illuminate\Http\Response|\Laravel\Lumen\Http\ResponseFactory
*/
function response($content = '', $status = 200, array $headers = [])
{
$factory = new Laravel\Lumen\Http\ResponseFactory;
if (func_num_args() === 0) {
return $factory;
}
return $factory->make($content, $status, $headers);
}
}
追記(ミドルウェアで行う方法)
ミドルウェアで行う方法を教えていただきました。
開発ブログ: JSONレスポンスのUnicodeエスケープ無効化はミドルウェアで!
すっきりしていて良いです。感動。