修改 Request headers 不完全攻略
如果某个接口即支持 form 请求又支持 api 请求,或者对于一些公共的处理程序(比如认证失败,输入校验失败等),我们往往需要根据请求类型来返回对应的 response 。
例如 Laravel 5.3 的 ValidatesRequests 中:
protected function buildFailedValidationResponse(Request $request, array $errors)
{
if ($request->expectsJson()) {
return new JsonResponse($errors, 422);
}
return redirect()->to($this->getRedirectUrl())
->withInput($request->input())
->withErrors($errors, $this->errorBag());
}
Request::expectsJson
实现如下:
public function expectsJson()
{
return ($this->ajax() && ! $this->pjax()) || $this->wantsJson();
}
而 Request::wantsJson
是通过 request headers 中的 Accept
来判断是否需要返回 JSON 响应。一般情况下,Api 客户端会在发送 HTTP 请求时设置头部 Accept: application/json
。
那么问题来了,如果 api client 没有传 Accept
头部信息或者里面没有包含 json
字样, $request->wantsJson()
就会返回 false
,从而导致给 api client 返回了不正确的响应。
解决方案一
可以在 expectsJson
后面再跟一个判断,比如:
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->expectsJson() || $request->is('api/*')) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}
return redirect()->guest('login');
}
Request::expectsJson
是 Laravel 5.3 新增的方法,使用 5.3 之前版本的话,可能需要这样写:
if (
($request->ajax() && ! $request->pjax()) ||
$request->wantsJson() ||
$request->is('api/*')
) {
//
}
或者把这一坨放到一个 helper 函数里方便调用:
function is_api_request()
{
$request = app('request');
return $request->...
}
需要注意的是 Laravel Framework 内部也使用了这个判断,例如本文开头提到的校验失败响应,所以要把相关的地方都重写下。并且每次升级 Framework 版本时还要检查一遍是不是内部又有别的地方使用了此类判断,或者之前重写的方法有变动。
显然,这个方案相当笨拙、不符合 Laravel 的优雅哲学。
解决方案二
既然服务端要兼容还挺麻烦,那么就强制所有的 api client 添加 Accept: application/json
请求头,违者后果自负。
这下整片雾霾都散了...
等等,老大刚说这个问题还是得服务端解决下。因为 iOS 客户端现在有几处 bug 就是这个问题导致的,而上个 iOS 版本被苹果审核了 3 个月,更何况 PHP 是世界上最好的语言,所以...
解决方案三
既然 Request::wantsJson
是通过头部信息判断的,那么我们能不能直接修改头部的 Accept
字段值添加 json
类型?答案是必须能。
- Where
借助 Laravel 强大的 IoC 技术, 我们可以注册 request 的绑定回调,比如app()->rebinding('request', ...)
. - How
如果$request->header('Accept')
不包含json
类型,我们就把application/json
添加上去,并通过$request->headers->set()
修改 header 值。 - When
越早越好。推荐放在某个非 deferred 的 service provider 的register
方法里。 - :warning: Note
判断原始值是否包含 json 类型时不能用expectsJson
,wantsJson
,accepts
,acceptsJson
等方法。因为Request
对Accept
值是懒加载并缓存的,所以调用过wantsJson
后再通过$request->headers->set('Accept', ...)
修改的值会不起作用。 - And
顺便把$_SERVER
相关值也修改下,万一有人用了呢。 -
Show me the code
class AppServiceProvider extends ServiceProvider { /** * Register any application services. */ public function register() { $this->addAcceptableJsonType(); } /** * Add "application/json" to the "Accept" header for the current request. */ protected function addAcceptableJsonType() { $this->app->rebinding('request', function ($app, $request) { if ($request->is('api/*')) { $accept = $request->header('Accept'); if (! str_contains($accept, ['/json', '+json'])) { $accept = rtrim('application/json,'.$accept, ','); $request->headers->set('Accept', $accept); $request->server->set('HTTP_ACCEPT', $accept); $_SERVER['HTTP_ACCEPT'] = $accept; } } }); } }
解决方案四
感谢 @Ryan 和 @oustn 的提醒,也可以把上述代码放到一个 middleware 里,然后给相关的请求添加这个 middleware.
至于用 middleware 还是用 service provider ,对于解决这个问题没多大区别,看个人喜好。详细讨论参见下面的评论。
通过路由middleware来对Request headers修改判断等操作也是一种优雅的方式
感觉略显麻烦啊 不如直接加个中间件吧,这个中间件不干啥事,就是给请求加一个请求头,然后所有的 api 路由都运用这个中间件,是不是会简单点
@Ryan @oustn 嗯,放在 middleware 里也可以。
推荐放到 service provider 的 register 方法里是考虑到 request 有很多方法会导致 accept 值被缓存,比如第三方扩展包里的 service provider,一旦 request 被读取过 accept 值,后面再修改 header 值都是无效的。
@ElfSundae 看了下源码,大概懂你的意思了。
accepts
,wantsJson
,acceptsJson
这三个方法确实是通过获取$this->acceptableContentTypes
来判断,而这个值是会在第一次读取 header 之后就被缓存的,后面修改 header 不起作用。但是expectsJson
和ajax
这俩是不会的,这俩是通过return 'XMLHttpRequest' == $this->headers->get('X-Requested-With');
来判断,所以 accept 缓存对这个没有影响,可以通过设置 header"X-Requested-With:XMLHttpRequest"
来达到这个目的。另外如果像你说的放在 Service Provider 的 register 方法里面,我觉得可能有点不太符合 Laravel 的思想,文档里面强调了不要在注册中做除了绑定的其他任何操作,虽然你的处理肯定不会有问题,正常来讲应该放在 boot 方法中,但是放在 boot 方法中就无法保证
$this->acceptableContentTypes
没有被缓存过。并且你这种办法只有放在
App\Providers\AppServiceProvider
里面是最最保险的,并且在 register 方法里面要放在第一位,这样才能保证没有其他的 Service Provider 可能会缓存$acceptableContentTypes
因为 ServiceProvider 也是有启动顺序的。探讨一下,没有其他意思 :)
@oustn 感谢回复。我发文也是希望有人讨论,大家互相学习 ;)
ajax
方法确实不会缓存 header 值,ajax client 也都会正确设置 X-Requested-With 头,不需要我们设置"X-Requested-With: XMLHttpRequest"
。但是
wantsJson
会缓存 Accept 头,而expectsJson
会调用到wantsJson
方法。本文修改请求头就是为了替 HTTP client (非 ajax) 添加application/json
的 Accept 头。有很多 api 设计的是请求时用 form urlencode 类型但是响应是 json 或 xml 类型,这样做是为了方便服务端和客服端解析数据。而大多数 HTTP client 库默认情况下不会对 form 请求设置 accept 值,从而导致了 Laravel 中的expectsJson
返回false
。你提到的针对 api 接口设置
X-Requested-With: XMLHttpRequest
也是一种解决办法,相当于是把所有 api client 都看做是 ajax client.放在 service provider 的 register 里是符合 Laravel 思想的,因为我们是通过
$this->app->rebinding
调用的是 Container 的绑定类方法,绑定了一个"request"
的绑定回调 callback ,并没有直接操作 Request 实例,所以这属于一种 register 行为而不是 boot 。Laravel Framework 也是用这种方法,比如给 Request 绑定 userResolver , 给UrlGenerator 绑定 routes等。不过你倒是提醒了我,判断
$request->is('api')
应该放到 rebinding 的 callback 里。:tada: 稍候我更新下文中代码。就目前我使用过的扩展包来看,还没有哪个在 register 方法里调用 request 的 wantsJson 等会导致缓存 $acceptableContentType 的相关方法,所以放到 service provider 和 middleware 效果是一样的,看个人喜好了。service provider 独立性更强一点而已。
文章已更新。
@ElfSundae 研究的很透啊,赞一个。
我那上面可能有两点没说清楚,补充一下哈
expectsJson
里面判断用的是或,在判断是ajax
后就不会去判断wantsJson
;你的方法当然也是很好的实践,涨姿势了,后面碰到这种情况有一个额外的选择。:100: