Response content must be a string or object ? (中) lumen的异常处理

100.jpg
记一次异常处理

项目前负责人添加的异常处理类
bootstrap/app.php

$app->singleton(
    Illuminate\Contracts\Debug\ExceptionHandler::class,
    App\Services\Exceptions\Handler::class
);

app\Services\Exceptions\Handler.php

<?php

namespace App\Services\Exceptions\Handler;

use Exception;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Laravel\Lumen\Exceptions\Handler as ExceptionHandler;
use Symfony\Component\HttpKernel\Exception\HttpException;

class Handler extends ExceptionHandler
{
    /**
     * A list of the exception types that should not be reported.
     * @var array
     */
    protected $dontReport = [
        AuthorizationException::class,
        HttpException::class,
        ModelNotFoundException::class,
        ValidationException::class,
    ];

    /**
     * Report or log an exception.
     * This is a great spot to send exceptions to Sentry, Bugsnag, etc.
     * @param  \Exception $e
     * @return void
     */
    public function report(Exception $e)
    {
        //var_dump($e->getTraceAsString());

        parent::report($e);
    }

    /**
     * Render an exception into an HTTP response.
     * @param  \Illuminate\Http\Request $request
     * @param  \Exception               $e
     * @return \Illuminate\Http\Response
     */
    public function render($request, Exception $e)
    {
        $code = 412;
        if (method_exists($e, 'getStatusCode')) {
            $code = $e->getStatusCode();
        }
        $ret = ['code' => $code, 'message' => '系统发生错误,请联系系统管理员!'];
        if (!method_exists($e, 'getStatusCode')) {
            if (env('APP_ENV','local') !== 'prod') {
                $ret['message'] = $e->getMessage();
                $ret['type'] = get_class($e);
                $ret['trace'] = explode('#', $e->getTraceAsString());
                $ret['file'] = $e->getFile();
                $ret['line'] = $e->getLine();
            }
        }
        else{
            $ret['message'] = $e->getMessage();
        }
        return new Response($ret, 200);
    }
}

这个异常处理类并没有什么特殊的内容,report只是将捕获的异常记录到日志里,render是把http请求的错误信息重新加工生成response返回给用户侧。
那这个异常Response content must be a string or object究竟是怎么出现的呢?

异常怎么出现的

错误溯源

先看一下记录异常的日志信息

UnexpectedValueException: The Response content must be a string or object implementing __toString(), "boolean" given. in F:\server\vendor\symfony\http-foundation\Response.php:399
Stack trace:
#0 F:\server\vendor\illuminate\http\Response.php(45): Symfony\Component\HttpFoundation\Response->setContent(false)
#1 F:\server\vendor\symfony\http-foundation\Response.php(206): Illuminate\Http\Response->setContent(false)
……

如果经常使用lumen调试,应该会遇到这个问题。我印象中见过几次这个异常,但从来没有仔细研究过这个异常是怎么产生的。这次刚好有时间,就想搞明白这个问题。
通过报错可以看出,错误是response对象的setContent方法传入了bool类型导致的,那就找到这个函数,打印函数堆栈信息,应该就能找到问题。

打印函数堆栈

    //\server\vendor\symfony\http-foundation\Response.php
    public function setContent($content)
    {
        $trace = debug_backtrace();
        // skip first since it's always the current method
        // array_shift($trace);
        // the call_user_func call is also skipped
        // array_shift($trace);
        $info = [];
        foreach ($trace as $item)
        {
            $info[] = [$item['file'], $item['line'], $item['function']];
        }
        var_dump($info); //本地调试
        //\App\Utils\Facades\Log::info("回溯记录:", [$info, self::$currentUser]);
        if (null !== $content && !\is_string($content) && !is_numeric($content) && !\is_callable([$content, '__toString'])) {
            throw new \UnexpectedValueException(sprintf('The Response content must be a string or object implementing __toString(), "%s" given.', \gettype($content)));
        }

        $this->content = (string) $content;

        return $this;
    }

打印的结果是

F:\server\vendor\symfony\http-foundation\Response.php:409:
array (size=7)

0 =>
array (size=4)
0 => string 'F:\server\vendor\illuminate\http\Response.php' (length=55)
1 => int 45
2 => string 'setContent' (length=10)
3 =>
array (size=1)
0 => boolean false
1 =>
array (size=4)
0 => string 'F:\server\vendor\symfony\http-foundation\Response.php' (length=63)
1 => int 206
2 => string 'setContent' (length=10)
3 =>
array (size=1)
0 => boolean false
2 =>
array (size=4)
0 => string 'F:\server\app\Services\Exceptions\Handler.php' (length=55)
1 => int 72
2 => string '__construct' (length=11)
3 =>
array (size=2)
0 =>
array (size=2)
...
1 => int 200
3 =>
array (size=4)
0 => string 'F:\server\vendor\laravel\lumen-framework\src\Routing\Pipeline.php' (length=75)
1 => int 78
2 => string 'render' (length=6)
3 =>
array (size=2)
0 =>
object(Illuminate\Http\Request)[104]
...
1 =>
object(Symfony\Component\HttpKernel\Exception\HttpException)[570]
...
4 =>
array (size=4)
0 => string 'F:\server\vendor\laravel\lumen-framework\src\Routing\Pipeline.php' (length=75)
1 => int 54
2 => string 'handleException' (length=15)
3 =>
array (size=2)
0 =>
object(Illuminate\Http\Request)[104]
...
1 =>
object(Symfony\Component\HttpKernel\Exception\HttpException)[570]
...

最开始看到打印结果时,因为堆栈信息比较简单,没法直观的看出为什么传入setContent的值为false的原因。我粗略看了下代码只考虑可能是前置某个函数修改了传入对象的$content数组,忘记了看函数体内部的修改,这样就很遗憾地与真正作出修改的函数插肩而过。

编辑器追踪

开启了phpstorm的断点调试之后(如何开启phpstorm的Xdebug断点调试

image.png

image.png

通过断点调试,定位到在执行了$content = $this->morphToJson($content);之后,$content的值发生了改变,这时我才注意到这个方法的调用改变了$content的值,真是个悲伤的故事。

捕获异常后的返回信息

通过上面的追踪,最后知道了$content = $this->morphToJson($content)方法改变了$content的值导致了一个新的异常抛出。

    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对象的构造方法传入的$content是一个数组。这个时候就清晰了,还记得错误日志里的那段字符串吗?

getaddrinfo failed: ��֪������������  

因为是使用的本地环境,报错返回的信息是GBK编码,传入给php后就无法解析了。以至于这段字符串无法被json_encode正确编码。

修复

知道原因后就好办了,要提前修改传入字符的编码格式。
回到错误处理的类文件App\Services\Exceptions\Handler修改其render方法。同样我也修改了日志格式化类LineFormatter.php里的stringify方法,如果不修改它,最终打印的日志也是无法被json_decode

    public function render($request, Exception $e)
    {
        $code = 412;
        if (method_exists($e, 'getStatusCode')) {
            $code = $e->getStatusCode();
        }
        $message = $e->getMessage();
        $encode = mb_detect_encoding($message, array("ASCII","UTF-8","GB2312","GBK",'BIG5'));
        //如果传入的字符串格式不是ASCll也不是utf8,那么要转为utf8格式
        !in_array($encode, ["ASCII","UTF-8"]) && $message = iconv($encode, 'UTF-8//IGNORE', $message);
        if(!is_numeric($message) && empty(json_encode($message)))
        {
            //如果已经是UTF8格式,但是json_encode报错,且错误码是JSON_ERROR_UTF8 (5),那么强制转换其格式
            $message = json_last_error() === JSON_ERROR_UTF8 ? mb_convert_encoding($message, "UTF-8", "UTF-8") : '';
        }
        $ret = ['code' => $code, 'message' => '系统发生错误,请联系系统管理员!'];
        if (!method_exists($e, 'getStatusCode')) {
            if (env('APP_ENV','local') !== 'prod') {
                $ret['message'] = $message;
                $ret['type'] = get_class($e);
                $ret['trace'] = explode('#', $e->getTraceAsString());
                $ret['file'] = $e->getFile();
                $ret['line'] = $e->getLine();
            }
        }
        else{
            $ret['message'] = $message;
        }
        return new Response($ret, 200);
    }
问题延申

可能会有人好奇异常捕获是怎么生效的,为什么所有的异常都能被注册的异常处理类捕获到?
其实我最开始也不能确定在各种错误测试时是分别在哪些步骤捕获了该异常。比如路由未设置的异常抛出,中间件的异常抛出,代码编译错误异常抛出,数据库错误异常抛出,用户手动throw异常,abort函数的异常等等,经过很长时间的源码查看,才终于弄清楚。
大部分的异常处理都是在bootstrap/app.php启动时注册到$app对象里的
Laravel\Lumen\Application

//Laravel\Lumen\Application
    /**
     * Create a new Lumen application instance.
     *
     * @param  string|null  $basePath
     * @return void
     */
    public function __construct($basePath = null)
    {
        if (! empty(env('APP_TIMEZONE'))) {
            date_default_timezone_set(env('APP_TIMEZONE', 'UTC'));
        }

        $this->basePath = $basePath;

        $this->bootstrapContainer();
        $this->registerErrorHandling();
        $this->bootstrapRouter();
    }

registerErrorHandling方法

    /**
     * Set the error handling for the application.
     *
     * @return void
     */
    protected function registerErrorHandling()
    {
        error_reporting(-1);

        set_error_handler(function ($level, $message, $file = '', $line = 0) {
            if (error_reporting() & $level) {
                throw new ErrorException($message, 0, $level, $file, $line);
            }
        });

        set_exception_handler(function ($e) {
            $this->handleUncaughtException($e);
        });

        register_shutdown_function(function () {
            $this->handleShutdown();
        });
    }

handleUncaughtException方法

    /**
     * Handle an uncaught exception instance.
     *
     * @param  \Throwable  $e
     * @return void
     */
    protected function handleUncaughtException($e)
    {
        $handler = $this->resolveExceptionHandler();

        if ($e instanceof Error) {
            $e = new FatalThrowableError($e);
        }

        $handler->report($e);

        if ($this->runningInConsole()) {
            $handler->renderForConsole(new ConsoleOutput, $e);
        } else {
            $handler->render($this->make('request'), $e)->send();
        }
    }

resolveExceptionHandler方法

    /**
     * Get the exception handler from the container.
     *
     * @return mixed
     */
    protected function resolveExceptionHandler()
    {
        if ($this->bound('Illuminate\Contracts\Debug\ExceptionHandler')) {
            return $this->make('Illuminate\Contracts\Debug\ExceptionHandler');
        } else {
            return $this->make('Laravel\Lumen\Exceptions\Handler');
        }
    }

这样也就理解了上一篇中,为什么要在bootstrap/app.php中添加

$app->singleton(
    Illuminate\Contracts\Debug\ExceptionHandler::class,
    App\Services\Exceptions\Handler::class
);

继续追踪代码,在Application.phptrait中也注册了异常捕获方法,这里是捕获与用户请求相关的异常并处理
Laravel\Lumen\Concerns\RoutesRequests

     * Dispatch the incoming request.
     *
     * @param  SymfonyRequest|null  $request
     * @return Response
     */
    public function dispatch($request = null)
    {
        list($method, $pathInfo) = $this->parseIncomingRequest($request);

        try {
            return $this->sendThroughPipeline($this->middleware, function () use ($method, $pathInfo) {
                if (isset($this->router->getRoutes()[$method.$pathInfo])) {
                    return $this->handleFoundRoute([true, $this->router->getRoutes()[$method.$pathInfo]['action'], []]);
                }

                return $this->handleDispatcherResponse(
                    $this->createDispatcher()->dispatch($method, $pathInfo)
                );
            });
        } catch (Exception $e) {
            return $this->prepareResponse($this->sendExceptionToHandler($e));
        } catch (Throwable $e) {
            return $this->prepareResponse($this->sendExceptionToHandler($e));
        }
    }
    /**
     * Send the request through the pipeline with the given callback.
     *
     * @param  array  $middleware
     * @param  \Closure  $then
     * @return mixed
     */
    protected function sendThroughPipeline(array $middleware, Closure $then)
    {
        if (count($middleware) > 0 && ! $this->shouldSkipMiddleware()) {
            return (new Pipeline($this))
                ->send($this->make('request'))
                ->through($middleware)
                ->then($then);
        }

        return $then();
    }

如果说laravel/lumen框架服务容器IOC的使用很炫酷,那么这里就又能学到一种非常棒的设计模式的使用。

流水线

Pipeline~流水线设计模式,当我在努力理解Pipeline类时,我发现它是如此的复杂,以至于我完全不能理解它的作用;但当我熟悉了这种设计模式,阅读了相关文档之后,我终于理解了它。如果你还有兴趣和我一起学习,请关注下一篇Response content must be a string or object ? (下) lumen的中间件使用与流水线设计模式pipeline

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,923评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,154评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,775评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,960评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,976评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,972评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,893评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,709评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,159评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,400评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,552评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,265评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,876评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,528评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,701评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,552评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,451评论 2 352

推荐阅读更多精彩内容