分享一個 JSON 相關小需求的解決過程與思路

overtrue發表於2019-04-29

分享一個 JSON 相關小需求的解決過程與思路

今天分享一個小技巧的解決過程。

起因

昨天同事問我,能不能在介面返回中不要將中文轉成 Unicode 編碼,因為這是 Laravel 框架做的事情,所以我們要實現這個效果無非就是在 json_encode 第二個引數中加入常量 JSON_UNESCAPED_UNICODE 選項即可,但是我們在控制器返回的是物件,或者是陣列,這個 encode 動作是框架最後輸出前完成的。應該是一個非常小小小的需求了。

啃原始碼

我花了 5 分鐘跟完原始碼,發現它在 Illuminate\Http\Response 中有這麼一段來完成 JSON 轉化的:

vendor/laravel/framework/src/Illuminate/Http/Response.php

if ($this->shouldBeJson($content)) {
    $this->header('Content-Type', 'application/json');

    $content = $this->morphToJson($content);
}

其中通過 shouldBeJson 這個方法來判斷當前的響應內容是否需要轉化成 JSON 格式:

vendor/laravel/framework/src/Illuminate/Http/Response.php

protected function shouldBeJson($content)
{
    return $content instanceof Arrayable ||
           $content instanceof Jsonable ||
           $content instanceof ArrayObject ||
           $content instanceof JsonSerializable ||
           is_array($content);
}

最後通過 morphToJson 完成了轉化動作:

vendor/laravel/framework/src/Illuminate/Http/Response.php

protected function morphToJson($content)
{
    if ($content instanceof Jsonable) {
        return $content->toJson();
    } elseif ($content instanceof Arrayable) {
        return json_encode($content->toArray());
    }

    return json_encode($content);
}

所以聰明的你已經發現了,這裡的 json_encode 沒有傳遞任何選項,所以我們無法通過簡單的方法呼叫來實現它。

解決方案1

既然最終出口是這麼幹的,那我立即想到一個簡單的處理方式:在 public/index.php 中輸出響應值前處理:

public/index.php

$response = $kernel->handle(
    $request = Illuminate\Http\Request::capture()
);

// 取到內容
$content = $response->original;

// 檢查原始內容的型別是否需要轉 json
if ($content instanceof Arrayable ||
    $content instanceof Jsonable ||
    $content instanceof ArrayObject ||
    $content instanceof JsonSerializable ||
    is_array($content)) {
    // 重新設定響應內容
    $response->setContent(json_encode($content, JSON_UNESCAPED_UNICODE));
}

$response->send();

就這樣輕鬆的搞定了這個需求。

強迫症犯了

雖然問題解決了,始終覺得這種改入口檔案的騷操作不太能接受,總覺得應該有更科學一點的方法,哪怕更科學一丟丟都行。

繼續探索

突然想到,我們的介面都是返回的是 Api Resource 模式,也就是說最後返回的都是 Illuminate\Http\Resources\Json\JsonResource 例項或者集合,那可否在這裡支援選項定義呢?

答案是可以:

Illuminate\Http\Resources\Json\JsonResource 中有一個 toResponse 方法:

vendor/laravel/framework/src/Illuminate/Http/Resources/Json/JsonResource.php

public function toResponse($request)
{
    return (new ResourceResponse($this))->toResponse($request);
}

它例項化並呼叫了 Illuminate\Http\Resources\Json\ResourceResponsetoResponse 的方法做為返回值:

vendor/laravel/framework/src/Illuminate/Http/Resources/Json/ResourceResponse.php

public function toResponse($request)
{
    return tap(response()->json(
        $this->wrap(
            $this->resource->resolve($request),
            $this->resource->with($request),
            $this->resource->additional
        ),
        $this->calculateStatus()
    ), function ($response) use ($request) {
        $response->original = $this->resource->resource;

        $this->resource->withResponse($request, $response);
    });
}

這個方法最後返回了 Illuminate\Http\JsonResponse,終於,我們發現這個類是支援選項定義的:

vendor/symfony/http-foundation/JsonResponse.php

protected $encodingOptions = self::DEFAULT_ENCODING_OPTIONS;

可以通過它的方法:setEncodingOptions($encodingOptions) 來傳遞我們想要的 json_encode 選項,所以,我們只需要在我們的 Resource 基類(我們介面返回值都使用了一個 Resource 基類 App\Http\Resources\Resource)中新增如下方法即可:

app/Http/Resources/Resource.php

/**
 * @param \Illuminate\Http\Request $request
 *
 * @return \Illuminate\Http\JsonResponse
 */
public function toResponse($request)
{
    return parent::toResponse($request)->setEncodingOptions(\JSON_UNESCAPED_UNICODE);
}

可是,我還沒來得及高興,問題又來了,某個介面由於不是標準的模型格式,沒有返回 Resource 例項,所以最後覺得這麼幹還是不行,必須得在 Laravel 輸出前統一處理。

終極解決方案

我想到了 Laravel 的 ternimate 中介軟體特性,然後發現不可行,因為你會發現在 public/index.php 中,ternimate 中介軟體的最後在響應輸出之後:

public/index.php

//...
$response->send();

$kernel->terminate($request, $response);

所以時機不合適。

那麼在這三行程式碼裡尋找答案吧:

public/index.php

$response = $kernel->handle(
    $request = Illuminate\Http\Request::capture()
);

我發現在這個邏輯的最後,在 Illuminate\Foundation\Http\Kernel 中有一個 handle 方法:

vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php

public function handle($request)
{
    try {
        $request->enableHttpMethodParameterOverride();

        $response = $this->sendRequestThroughRouter($request);
    } catch (Exception $e) {
        $this->reportException($e);

        $response = $this->renderException($request, $e);
    } catch (Throwable $e) {
        $this->reportException($e = new FatalThrowableError($e));

        $response = $this->renderException($request, $e);
    }

    $this->app['events']->dispatch(
        new Events\RequestHandled($request, $response)
    );

    return $response;
}

上面最後部分有一個事件 Illuminate\Foundation\Http\Events\RequestHandled 被觸發,所以這裡就是突破口了:監聽這個事件,修改 $response 的內容。

建立一個事件監聽器:

$ ./artisan make:listener SetResponseEncodingOptions --event=Illuminate\Foundation\Http\Events\RequestHandled

程式碼如下:

app/Listensers/SetResponseEncodingOptions

<?php

namespace App\Listeners;

use ArrayObject;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Foundation\Http\Events\RequestHandled;

class SetResponseEncodingOptions
{
    /*...*/
    public function handle(RequestHandled $event)
    {
        $content = $event->response->original;

        if ($content instanceof Arrayable ||
            $content instanceof Jsonable ||
            $content instanceof ArrayObject ||
            $content instanceof \JsonSerializable ||
            is_array($content)) {
            $event->response->setContent(json_encode($content, \JSON_UNESCAPED_UNICODE));
        }
    }
}

配置監聽規則:

app/Providers/EventServiceProvider.php

protected $listen = [
    //...
    \Illuminate\Foundation\Http\Events\RequestHandled::class => [
        \App\Listeners\SetResponseEncodingOptions::class,
    ],
];

終於,找到了一個看起來合理的做法解決了這個小小小需求。

相關文章