前述
如果laravel和lumen框架使用的多,一定会碰到这个异常: The Response content must be a string or object implementing __toString(), "boolean" given,也正是由这个异常,我开始决定写相关的笔记。
起因
公司项目使用的lumen框架,前负责人还是很良心的,对lumen框架也是十分精通。通过框架依赖注入和服务注册的能力,添加了一个异常处理的类,在任何异常发生时捕获并把错误信息记录到日志文件,向前端返回一个json数据。之所以这么做,我猜是因为lumen自身默认的异常处理类会返回一个状态吗为500且渲染过的页面,不适用于前后端分离的项目使用;同时状态码为500更容易看出是服务挂掉了,显得有点low。最近我在重构此项目时,有一个需求是要修改日志的格式。
使用过的同学应该知道lumen的日志格式很友好,message+array的格式,并会通过formater注入日志时间。
Log::info("index_action_info", [$request->input()]);
//[2021-03-07 02:47:13] local.INFO: index_action_info [{"info":"1","query_info":"test_log_info"}]
为什么修改呢,因为响应平台化运维要求,把日志文件接入到ELK,要求上报的数据每条都是json格式,实际上这个修改也比较简单,异常处理的日志格式修改稍微复杂一点,不过也能处理,下面记录一下实现过程。
经过
日志格式修改
lumen5.5版本及之前,bootstrap/app.php
文件开启门面加载配置$app->withFacades()
,使用的日志类facade(门面类)Illuminate\Support\Facades\Log
很容易找到。
namespace Illuminate\Support\Facades;
use Psr\Log\LoggerInterface;
/**
* @see \Illuminate\Log\Writer
*/
class Log extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return LoggerInterface::class;
}
}
可以看出日志写入时使用的对象是Psr\Log\LoggerInterface
,但很明显它只是一个接口。深入了解可以看到lumen
在laravel\lumen\Application
核心类文件中使用IOC容器注册服务绑定了log
与Psr\Log\LoggerInterface
这两个服务别名。
'log' => 'registerLogBindings',
'Psr\Log\LoggerInterface' => 'registerLogBindings',
/**
* Register container bindings for the application.
*
* @return void
*/
protected function registerLogBindings()
{
$this->singleton('Psr\Log\LoggerInterface', function () {
if ($this->monologConfigurator) {
return call_user_func($this->monologConfigurator, new Logger('lumen'));
} else {
return new Logger('lumen', [$this->getMonologHandler()]);
}
});
}
服务容器的具体实现过程我就不赘述了,可以点击查看这里。总之如果使用log门面,那么最终处理日志记录的是registerLogBindings
函数中返回的Logger
对象,那么改写日志格式有两种方式:
- 一是注册一个新的门面类
NewLog
,注册自己的日志处理类app->bind('newLog', MyNewLog::class)
,不过这种修改方式的缺点很明显,很多处理方法都要自己写,还很容易出bug。 - 二是修改日志处理类的处理方法。通过上面的
registerLogBindings
函数可以看出,最终处理日志的类是Monolog\Logger
,如果app
对象中monologConfigurator
属性被赋值为匿名函数,那么会返回被其处理之后的Logger
对象,我理解这算是装饰器的设计模式的使用吧。
综上选择第二种实现方法
步骤一:在/app/Providers
目录下创建一个服务
<?php
/**
* @Copyright ***
* @Name LogServiceProvider.php
* @Version Beta 1.0
* @Date: 2019-01-16
* @Description 文件
* <author> <time> <version > <desc>
* *** 2019-01-16 Beta 1.0 第一次建立该文件
*/
namespace App\Providers;
//use Monolog\Formatter\LineFormatter;
use App\Services\Formatter\LineFormatter;
use Monolog\Logger as Monolog;
use Illuminate\Support\ServiceProvider;
use App\Utils\Monolog\Writer;
class LogServiceProvider extends ServiceProvider
{
/**
* @desc 日志处理类
* @var Monolog
*/
protected $logger;
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$format = "{\"datetime\":%datetime%, \"channel\":%channel%, \"level\":%level_name%, \"message\":%message%, \"context\":%context%, \"extra\": %extra%}\n";
//日志处理
$this->app->configureMonologUsing(function (Monolog $logger) use($format) {
$monologHandlers = $logger->getHandlers();
if(empty($monologHandlers))
{
$monologHandler = new \Monolog\Handler\RotatingFileHandler($this->app->storagePath() . '/logs/lumen.log', 30, true);
$monologHandler->setFormatter(new LineFormatter($format, 'Y-m-d H:i:s.u', true, true));
$logger->pushHandler($monologHandler);
}
return $logger;
});
}
}
可以看到在register方法里,先定义日志格式$format
字符串,模拟了一个json字符串。LineFormatter
是lumen
用于日志格式化的类,$format
就是其格式化所使用的的格式。
写入日志的调用如Log::info($mesage, $content)
正常会传入两个参数,上面说了Log
服务别名对应的日志服务是Monolog/Logger
;它须有一个日志处理者$monologHandler
。
$monologHandler
提供日志文件创建,日志写入等功能。方法中使用的\Monolog\Handler\RotatingFileHandler
是lumen
提供的按天生成日志文件写、入日志,并删除超过30天的日志处理服务。
//
lumen
框架里Monolog/Logger
类中定义了info方法
public function info($message, array $context = array())
{
return $this->addRecord(static::INFO, $message, $context);
}
//而addRecord方法中会创建一个$record
数组,然后调用$handler->handle($record)
$record = array(
'message' => (string) $message,
'context' => $context,
'level' => $level,
'level_name' => $levelName,
'channel' => $this->name,
'datetime' => $ts,
'extra' => array(),
);
try {
foreach ($this->processors as $processor) {
$record = call_user_func($processor, $record);
}
while ($handler = current($this->handlers)) {
if (true === $handler->handle($record)) {
break;
}
next($this->handlers);
}
} catch (Exception $e) {
$this->handleException($e, $record);
}
//其如datetime
,level
,context
参数根据字符串或数组的格式输入,这样就可以把整条日志用$format
格式改写,达到目的。其过程是handler方法中执行$record['formatted'] = $this->getFormatter()->format($record);
然后fwrite($stream, (string) $record['formatted']);
把格式化的结果写入到日志文件中
步骤二:改写LineFormatter类
在正常的lumen日志中,其每行展示均是字符串,如果修改了$format
就要兼容数组的展示。所以需要把$record['formatted']
的每个json
对象的value
转为json
格式,再使用$format
格式化才可以变成完整的json
格式。于是我复制了MonoLog/Formatter/LineFormatter.php
文件,改写后注册到$monologHandler
对象中。
<?php
/*
* This file is part of the Monolog package.
*
* (c) Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Services\Formatter;
use Monolog\Formatter\NormalizerFormatter;
use Monolog\Utils;
/**
* Formats incoming records into a one-line string
*
* This is especially useful for logging to files
*
* @author Jordi Boggiano <j.boggiano@seld.be>
* @author Christophe Coevoet <stof@notk.org>
*/
class LineFormatter extends NormalizerFormatter
{
const SIMPLE_FORMAT = "[%datetime%] %channel%.%level_name%: %message% %context% %extra%\n";
protected $format;
protected $allowInlineLineBreaks;
protected $ignoreEmptyContextAndExtra;
protected $includeStacktraces;
/**
* @param string $format The format of the message
* @param string $dateFormat The format of the timestamp: one supported by DateTime::format
* @param bool $allowInlineLineBreaks Whether to allow inline line breaks in log entries
* @param bool $ignoreEmptyContextAndExtra
*/
public function __construct($format = null, $dateFormat = null, $allowInlineLineBreaks = false, $ignoreEmptyContextAndExtra = false)
{
$this->format = $format ?: static::SIMPLE_FORMAT;
$this->allowInlineLineBreaks = $allowInlineLineBreaks;
$this->ignoreEmptyContextAndExtra = $ignoreEmptyContextAndExtra;
parent::__construct($dateFormat);
}
public function includeStacktraces($include = true)
{
$this->includeStacktraces = $include;
if ($this->includeStacktraces) {
$this->allowInlineLineBreaks = true;
}
}
public function allowInlineLineBreaks($allow = true)
{
$this->allowInlineLineBreaks = $allow;
}
public function ignoreEmptyContextAndExtra($ignore = true)
{
$this->ignoreEmptyContextAndExtra = $ignore;
}
/**
* {@inheritdoc}
*/
public function format(array $record)
{
$vars = parent::format($record);
$output = $this->format;
if(!empty($var['extra']))
{
foreach ($vars['extra'] as $var => $val) {
if (false !== strpos($output, '%extra.'.$var.'%')) {
$output = str_replace('%extra.'.$var.'%', $this->stringify($val), $output);
unset($vars['extra'][$var]);
}
}
}
else
{
$var['extra'] = [];
}
if(!empty($var['context']))
{
foreach ($vars['context'] as $var => $val) {
if (false !== strpos($output, '%context.'.$var.'%')) {
$output = str_replace('%context.'.$var.'%', $this->stringify($val), $output);
unset($vars['context'][$var]);
}
}
}
else
{
$var['context'] = [];
}
if ($this->ignoreEmptyContextAndExtra) {
if (empty($vars['context'])) {
unset($vars['context']);
$output = str_replace('%context%', '[]', $output);
}
if (empty($vars['extra'])) {
unset($vars['extra']);
$output = str_replace('%extra%', '[]', $output);
}
}
foreach ($vars as $var => $val) {
if (false !== strpos($output, '%'.$var.'%')) {
$output = str_replace('%'.$var.'%', $this->stringify($val), $output);
}
}
// remove leftover %extra.xxx% and %context.xxx% if any
if (false !== strpos($output, '%')) {
$output = preg_replace('/%(?:extra|context)\..+?%/', '', $output);
}
return $output;
}
public function formatBatch(array $records)
{
$message = '';
foreach ($records as $record) {
$message .= $this->format($record);
}
return $message;
}
public function stringify($value)
{
$retValue = $this->replaceNewlines($this->convertToString($value));
$res = json_encode($retValue);
if($res === false && json_last_error() === JSON_ERROR_UTF8)
{
return json_encode(mb_convert_encoding($retValue, "UTF-8", "UTF-8"));
}
return $res;
}
protected function normalizeException($e)
{
// TODO 2.0 only check for Throwable
if (!$e instanceof \Exception && !$e instanceof \Throwable) {
throw new \InvalidArgumentException('Exception/Throwable expected, got '.gettype($e).' / '.Utils::getClass($e));
}
$previousText = '';
if ($previous = $e->getPrevious()) {
do {
$previousText .= ', '.Utils::getClass($previous).'(code: '.$previous->getCode().'): '.$previous->getMessage().' at '.$previous->getFile().':'.$previous->getLine();
} while ($previous = $previous->getPrevious());
}
$str = '[object] ('.Utils::getClass($e).'(code: '.$e->getCode().'): '.$e->getMessage().' at '.$e->getFile().':'.$e->getLine().$previousText.')';
if ($this->includeStacktraces) {
$str .= "\n[stacktrace]\n".$e->getTraceAsString()."\n";
}
return $str;
}
protected function convertToString($data)
{
if (null === $data || is_bool($data)) {
return var_export($data, true);
}
if (is_scalar($data)) {
return (string) $data;
}
if (version_compare(PHP_VERSION, '5.4.0', '>=')) {
return $this->toJson($data, true);
}
return str_replace('\\/', '/', $this->toJson($data, true));
}
protected function replaceNewlines($str)
{
if ($this->allowInlineLineBreaks) {
if (0 === strpos($str, '{')) {
return str_replace(array('\r', '\n'), array("\r", "\n"), $str);
}
return $str;
}
return str_replace(array("\r\n", "\r", "\n"), ' ', $str);
}
}
对比原文件,我修改了format
方法与stringify
方法;前者是为了在extra
、context
和其他日志数组元素为空时赋值为空数组,后者是将这些元素的值转为json
格式。
步骤三:在bootstrap/app.php
注册一个服务提供者
$app->register(App\Providers\LogServiceProvider::class);
最终日志效果
{"datetime":"2021-03-08 10:28:43.978073", "level":"lumen.INFO", "message":"_login_user_info_:", "context":{"user_id":1,"user_name":"admin","input":{"uid":"20458","portal_id":"870","table_id":"12935","distinct":"1","field_id":["168208"],"paging":{"page":"1","size":"21"},"count":"0"},"php://input":""}, "extra": ""}
{"datetime":"2021-03-08 10:28:46.085127", "level":"lumen.ERROR", "message":"PDOException: PDO::__construct(): php_network_getaddresses: getaddrinfo failed: ��֪������������ in F:\server\vendor\illuminate\database\Connectors\Connector.php:67
Stack trace:
#0 F:\server\vendor\illuminate\database\Connectors\Connector.php(67): PDO->__construct('mysql:host=tbds...', 'ponybi', 'ponyb1@MB', Array)
#1 F:\server\vendor\illuminate\database\Connectors\Connector.php(43): Illuminate\Database\Connectors\Connector->createPdoConnection('mysql:host=tbds...', 'ponybi', 'ponyb1@MB', Array)
#2 F:\server\vendor\illuminate\database\Connectors\MySqlConnector.php(24): Illuminate\Database\Connectors\Connector->createConnection('mysql:host=tbds...', Array, Array)
······
#47 F:\server\public\index.php(34): Laravel\Lumen\Application->run()
#48 {main}", "context":, "extra": ""}
{"datetime":"2021-03-08 10:28:46.086727", "level":"lumen.ERROR", "message":"用户查询数据错误", "context":null, "extra": ""}
可以看到无论是用户主动记录的日志还是抛出的异常,以及系统异常都会被记录,格式也都符合json格式
异常的错误日志
测试抛出了一个关键异常
改完了日志格式后就是测试阶段,我在写测试用例时,模拟了http,system,MySQL链接超时中断异常等等。
上面的最终效果展示中有这样一段,实际上这是修改了报错处理类修改之后的日志展示
getaddrinfo failed: ��֪������������
{"datetime":"2021-03-08 10:28:46.086727", "level":"lumen.ERROR", "message":"用户查询数据错误", "context":null, "extra": ""}
之所以修改,是因为当测试到pdo异常时突然发现多了一个异常。这个相信也有不少人见到过:
{"datetime":"2021-03-08 11:37:05.929497", "level":"lumen.ERROR", "message":"用户查询数据错误", "context":null, "extra": ""}
{"datetime":"2021-03-08 11:37:05.931661", "level":"lumen.ERROR", "message":"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)
#2 F:\server\app\Services\Exceptions\Handler.php(65): Symfony\Component\HttpFoundation\Response->__construct(Array, 200)
#3 F:\server\vendor\laravel\lumen-framework\src\Routing\Pipeline.php(78): App\Services\Exceptions\Handler->render(Object(Illuminate\Http\Request), Object(Symfony\Component\HttpKernel\Exception\HttpException))
#4 F:\server\vendor\laravel\lumen-framework\src\Routing\Pipeline.php(54): Laravel\Lumen\Routing\Pipeline->handleException(Object(Illuminate\Http\Request), Object(Symfony\Component\HttpKernel\Exception\HttpException))
#5 F:\server\app\Http\Middleware\Authenticate.php(89): Laravel\Lumen\Routing\Pipeline->Laravel\Lumen\Routing\{closure}(Object(Illuminate\Http\Request))
#6 F:\server\vendor\illuminate\pipeline\Pipeline.php(148): App\Http\Middleware\Authenticate->handle(Object(Illuminate\Http\Request), Object(Closure))
#7 [internal function]: Illuminate\Pipeline\Pipeline->Illuminate\Pipeline\{closure}(Object(Illuminate\Http\Request))
#8 F:\server\vendor\laravel\lumen-framework\src\Routing\Pipeline.php(32): call_user_func(Object(Closure), Object(Illuminate\Http\Request))
#9 F:\server\app\Http\Middleware\AuthOrigin.php(50): Laravel\Lumen\Routing\Pipeline->Laravel\Lumen\Routing\{closure}(Object(Illuminate\Http\Request))
#10 F:\server\vendor\illuminate\pipeline\Pipeline.php(148): App\Http\Middleware\AuthOrigin->handle(Object(Illuminate\Http\Request), Object(Closure))
#11 [internal function]: Illuminate\Pipeline\Pipeline->Illuminate\Pipeline\{closure}(Object(Illuminate\Http\Request))
#12 F:\server\vendor\laravel\lumen-framework\src\Routing\Pipeline.php(32): call_user_func(Object(Closure), Object(Illuminate\Http\Request))
#13 F:\server\vendor\illuminate\pipeline\Pipeline.php(101): Laravel\Lumen\Routing\Pipeline->Laravel\Lumen\Routing\{closure}(Object(Illuminate\Http\Request))
#14 F:\server\vendor\laravel\lumen-framework\src\Concerns\RoutesRequests.php(410): Illuminate\Pipeline\Pipeline->then(Object(Closure))
#15 F:\server\vendor\laravel\lumen-framework\src\Concerns\RoutesRequests.php(256): Laravel\Lumen\Application->sendThroughPipeline(Array, Object(Closure))
#16 F:\server\vendor\laravel\lumen-framework\src\Concerns\RoutesRequests.php(230): Laravel\Lumen\Application->handleFoundRoute(Array)
#17 F:\server\vendor\laravel\lumen-framework\src\Concerns\RoutesRequests.php(164): Laravel\Lumen\Application->handleDispatcherResponse(Array)
#18 F:\server\vendor\laravel\lumen-framework\src\Concerns\RoutesRequests.php(413): Laravel\Lumen\Application->Laravel\Lumen\Concerns\{closure}()
#19 F:\server\vendor\laravel\lumen-framework\src\Concerns\RoutesRequests.php(166): Laravel\Lumen\Application->sendThroughPipeline(Array, Object(Closure))
#20 F:\server\vendor\laravel\lumen-framework\src\Concerns\RoutesRequests.php(107): Laravel\Lumen\Application->dispatch(NULL)
#21 F:\server\public\index.php(34): Laravel\Lumen\Application->run()
#22 {main}", "context":, "extra": ""}
The Response content must be a string or object implementing __toString(), "boolean" given
错误的意思很明显,response只能是字符串。这就很奇怪了,明明抛出了异常,返回的必定是一个数组,为什么底层异常处理类会说是一个bool类型呢?具体修复过程,下篇文章:Response content must be a string or object ? (中) lumen的异常处理我再说明