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

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

你可能感兴趣的:(Response content must be a string or object ? (中) lumen的异常处理)