Help us understand the problem. What is going on with this article?

Laravelのjson応答でユニコードのエスケープをやめさせる

More than 1 year has passed since last update.

はじめに

発端は、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エスケープ無効化はミドルウェアで!

すっきりしていて良いです。感動。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした