通过 header 中的 version 字段来做 API 多版本兼容
需求是这样的
大家都知道,一般要在 Laravel 中做 api 版本兼容,都是这样:
Route::prefix('v1')->group(function () {
Route::get('users', function () {
// Matches The "/v1/users" URL
});
});
不过最近的一个项目的需求是要把 version
字段放到 header
中,app的同学觉得不用直接改url真是极好的。于是这两天研究了一下。
考虑过dingo/api
首先想到的自然是 dingo/api ,不过考虑到除了把 version
加到 header
中这个需求以外,其他需求用 Laravel 自带的 api 机制就能满足,为了一个小小的需求就直接上 dingo/api
感觉也并不好,最重要的是写 dingo/api
的路由还没有提示,于是打算自己来写。
代码在这里 zedisdog/laravel-change-way ,各位多多指教。我们进入正题。
第一个版本是失败的
核心思想
第一个版本借鉴的 Luminee/escalator 这个包,大概的核心思想是这样的:
首先, Laravel 中负责将请求分发到控制器的是 \Illuminate\Routing\ControllerDispatcher
/**
* Dispatch a request to a given controller and method.
*
* @param \Illuminate\Routing\Route $route
* @param mixed $controller
* @param string $method
* @return mixed
*/
public function dispatch(Route $route, $controller, $method)
{
$parameters = $this->resolveClassMethodDependencies(
$route->parametersWithoutNulls(), $controller, $method
);
if (method_exists($controller, 'callAction')) {
return $controller->callAction($method, $parameters);
}
return $controller->{$method}(...array_values($parameters));
}
这个 dispatch
方法接受的第二个参数是一个控制器对象,就想到可以在 dispatch
逻辑执行之前把这个 $controller
替换成任意我们想执行的控制器。
实现
基于这个思想,写出了第一个版本。
写一个继承 \Illuminate\Routing\ControllerDispatcher
的子类,改写 dispatch
方法,如下:
public function dispatch(Route $route, $controller, $method)
{
$request = $this->container->make('request');
$version = $request->header('version');
if ($version && $version !== 'v1') {
$class = get_class($controller);
$class_path = explode('\\', $class);
array_splice($class_path, -1, 0, $version);
$controller = $this->container->make(implode('\\', $class_path));
}
return parent::dispatch($route, $controller, $method);
}
效果
定义一个路由
Route::get('/test','TestController@test');
写两个控制器类 App\Http\Controllers\TestController
和 App\Http\Controllers\v2\TestController
,并且都实现一个 test
方法。
这样当 header
里面的 version
字段为 v1
或者没有 version
字段的时候,请求会分发到 App\Http\Controllers\TestController::test
方法中,而当 version
字段为 v2
的时候,请求会分发到 App\Http\Controllers\v2\TestController::test
方法中。
有问题
看了这篇帖子的讨论,又自己考虑的了一下,发现有以下问题:
- 如果路由到一个不存在的控制器,会直接报找不到类的错误,不友好。
- 虽然约定优于配置,不过一定要把两个版本的控制器写成相同的名字,似乎有点太束缚了。
- 修改控制器的时机并不好,在修改之前,原本会被路由到的控制器已经被实例化了,这是不必要的损耗。并且,如果路由一个
v1
里面没有但是v2
有的控制器,就会报404
。
第二个版本
第二个版本则是借鉴了 dingo/api
的思想,从根本上下手,直接改 Illuminate\Routing\Router
以及相关的几个类。对于路由的介绍,大家可以看这篇文章。
文章中详细的描述了路由条目在路由类中的结构:
Illuminate\Routing\Router::$routes
保存了所有的路由。- 这个
Illuminate\Routing\Router::$routes
是Illuminate\Routing\RouteCollection
。 - 在
Illuminate\Routing\RouteCollection
中,$allRoutes
保存了所有的路由。另外,所有路由被根据路由名和控制器来索引,分别又放到另外两个成员变量中:$nameList
和$actionList
,应该是为了方便查找。
实现
这次的思想是:在所有路由读取出来以后,想办法将路由根据 version
分组。然后把最开始解析路由的逻辑改成根据 header
中的 version
字段来解析。
Dezsidog\Routing\Route
首先是最基本的单元 Route
,扩展 Illuminate\Routing\Route
,给 action
添加一个 version
字段,并且设置 setter
和 getter
。
declare(strict_types=1);
namespace Dezsidog\Routing;
use Illuminate\Routing\Route as LaravelRoute;
class Route extends LaravelRoute
{
/**
* set the version property
* @param null|string $version
* @return Route
*/
public function setVersion(?string $version): self
{
$version ? $this->action['version'] = $version : $this->action['version'] = 'v1';
return $this;
}
/**
* get the version property
* @return null|string
*/
public function getVersion(): ?string
{
$this->action['version'] = $this->action['version'] ?? 'v1';
return $this->action['version'];
}
}
Dezsidog\Routing\RouteGroup
扩展 Illuminate\Routing\RouteGroup
,使其能够支持
Route::group(['version' => 'v1'], function () {
...........
});
这样的写法。
declare(strict_types=1);
namespace Dezsidog\Routing;
use Illuminate\Routing\RouteGroup as LaravelRouteGroup;
use Illuminate\Support\Arr;
class RouteGroup extends LaravelRouteGroup
{
public static function merge($new, $old)
{
if (isset($new['domain'])) {
unset($old['domain']);
}
$new = array_merge(static::formatAs($new, $old), [
'namespace' => static::formatNamespace($new, $old),
'prefix' => static::formatPrefix($new, $old),
'where' => static::formatWhere($new, $old),
'version' => static::formatVersion($new, $old),
]);
return array_merge_recursive(Arr::except(
$old, ['namespace', 'prefix', 'where', 'as']
), $new);
}
public static function formatVersion($new, $old)
{
return $old['version'];
}
}
Dezsidog\Routing\RouteCollection
扩展 Illuminate\Routing\RouteCollection
,新加一个 $version
成员变量,保存根据版本分组的路由。
declare(strict_types=1);
namespace Dezsidog\Routing;
use Illuminate\Routing\RouteCollection as LaravelRouteCollection;
class RouteCollection extends LaravelRouteCollection
{
/**
* @var RouteCollection[]
*/
protected $versions = [];
/**
* @param Route $route
*/
public function addLookups($route)
{
parent::addLookups($route);
$this->createVersion($route->getVersion());
$this->versions[$route->getVersion()]->add($route);
}
public function version(string $version): \Illuminate\Routing\RouteCollection
{
return $this->versions[$version] ?? null;
}
public function createVersion($version)
{
if (!isset($this->versions[$version])) {
$this->versions[$version] = new \Illuminate\Routing\RouteCollection();
}
}
}
Dezsidog\Routing\Router
扩展 Illuminate\Routing\Router
,添加一个根据版本分发路由的方法。要做的就是,根据 version
在 Dezsidog\Routing\Router::$versions
中找到对应版本的路由分组,然后克隆一个 Dezsidog\Routing\Router
(也就是自己),把找到的路由分组设置到克隆出的对象中。这样,这个克隆出的对象中的路由就只有当前版本分组中的路由了,然后用新克隆的对象来调用 dispatch
方法。(这里是跟 dingo/api
学的 :laughing: )
declare(strict_types=1);
namespace Dezsidog\Routing;
use Illuminate\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Routing\Router as LaravelRouter;
class Router extends LaravelRouter
{
/**
* @var RouteCollection $routes
*/
protected $routes;
public function __construct(Dispatcher $events, Container $container = null)
{
parent::__construct($events, $container);
$this->routes = new RouteCollection();
}
public function dispatchByVersion(Request $request, string $version)
{
if (! $routes = $this->routes->version($version)) {
throw new \RuntimeException('unknown route version');
}
$router = clone $this;
$router->setRoutes($routes);
$response = $router->dispatch($request);
unset($router);
return $response;
}
/**
* Create a new Route object.
*
* @param array|string $methods
* @param string $uri
* @param mixed $action
* @return \Illuminate\Routing\Route
*/
protected function newRoute($methods, $uri, $action)
{
return (new Route($methods, $uri, $action))
->setRouter($this)
->setContainer($this->container);
}
}
Dezsidog\Http\Kernel
最后是扩展 Illuminate\Foundation\Http\Kernel
。 dingo/api
是使用一个中间件来在请求刚刚进入的时候就获取到控制权。这里因为只是需要小小的改变一下路由解析逻辑,并且也不想对框架的其他部分造成影响。所以才直接扩展 Kernel
,不好的地方就是一定要去把 App/Http/Kernel
所继承的类改成本类( Dezsidog\Http\Kernel
)。
namespace Dezsidog\Http;
use Dezsidog\Routing\Router;
use Illuminate\Foundation\Http\Kernel as LaravelHttpKernel;
use Illuminate\Http\Request;
class Kernel extends LaravelHttpKernel
{
/**
* @var Router
*/
protected $router;
/**
* Get the route dispatcher callback.
*
* @return \Closure
*/
protected function dispatchToRouter()
{
return function ($request) {
/**
* @var Request $request
*/
$this->app->instance('request', $request);
$oldRouter = $this->router;
$this->router = $this->app->make('router');
foreach ($oldRouter->getMiddlewareGroups() as $key => $value) {
$this->router->middlewareGroup($key, $value);
}
foreach ($oldRouter->getMiddleware() as $key => $value) {
$this->router->aliasMiddleware($key, $value);
}
return $this->router->dispatchByVersion($request, $request->header('version', 'v1'));
};
}
}
最后,在框架启动时,把原来的 Router
类换成自己写的 Router
类就大功告成了。
最后是测试
Route::get('test', "V1Controller@test");
Route::group(['version' => 'v2'], function(){
Route::get('test', "V2Controller@test");
Route::get('test2', "V2Controller@test2");
Route::get('test3', "V1Controller@test");
});
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class V1Controller extends Controller
{
public function test()
{
return 'v1';
}
}
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class V2Controller extends Controller
{
public function test()
{
return 'v2';
}
public function test2()
{
return 'v2';
}
}
public function testVersion()
{
$response = $this->getJson('api/test');
$this->assertEquals('v1', $response->content());
$response = $this->getJson('api/test',['version' => 'v2']);
$this->assertEquals('v2', $response->content());
$response = $this->getJson('api/test2', ['version' => 'v2']);
$this->assertEquals('v2', $response->content());
$response = $this->getJson('api/test2');
$response->assertStatus(404);
$response = $this->getJson('api/test3',['version' => 'v2']);
$this->assertEquals('v1', $response->content());
}
在这里请教一下。一般写RESTful api的时候 返回的数据格式需要在外面包裹一层吗?
比如传统我们写是
但是最近看了一些api的demo发现部分是直接返回了data字段里的数据,并没有包裹。而客户端是通过
http status code
来判断的,并不是code
这个字段。但是呢,貌似在错误的时候,又会返回一个
error_code
类似的字段来说明错误。想请教下目前主流的是不是都这么弄?@keer 通过
http status code
来判断是标准的做法,不过要不要这样做,具体还是看需求和开发成本。虽然与接口对接的其他系统肯定都能通过判断你给的code来完成业务逻辑,但如果你一开始就返回404
可能有些系统就能少执行一部分代码。另外,http status code
就只有那么些,肯定没法表示系统中所有的错误,比如, 400这个错误,很可能表示很多种错误。于是才会有error_code这种解决方式。通常我更倾向于使用http status code
,有时也会因为这个被其他人怼,哈哈哈哈。@zedisdog 感谢。确实有团队的开发成本。上周和前端沟通了下使用这个
http status code
可是貌似对这个并不敏感。可能会增加前端或者app开发者的学习成本。但是我又想使用标准的规范(毕竟我也没写过这种,所以想尝试下)。纠结。。。可能还是后期开会在讨论了。 :joy:
@keer app那块可能确实会有点麻烦,不过前端还好啦。前端Promise里面的
then
和catch
就是一个例子,then
是http status code
在200到299这个范围(也就是成功)执行的,而catch
就是大于等于400
会执行的。另外,jquery里面的ajax也会有success
和error
这两个回调可以使用,原理也是一样的。有点折腾
@carlclone 我也不想折腾,有好的建议,欢迎指教 :smile:
hi,根据文章一步一步做,在往
AppServiceProvider
的register
加载会导致框架加载超时。。
另外尝试使用你的 composer 包来实现,也是失败,,
本地环境:mac + php 7.2.5 + Nginx 1.13.12 + Laravl 5.5.28
@Atzcl 你好,我只看到有5.5.28,没看到5.5.40,不过问题已经修复了,请更新这个包。非常感谢你的反馈。
很好!
@zedisdog Ok,感谢~
@ThinkCsly 感谢支持
@Atzcl 这篇帖子我也改过了,如果喜欢折腾,可以再自己做一个,哈哈哈
状态码的话我比较喜欢全部统一返回数据格式
服务器把 500,404 等等错误也捕获,然后也通过 code 返回状态码,这样子前端比较舒服点。
至于 api 版本,现在的前端都有设置一个base_url,这样的时候,只需要在配置文件写,
http://domain.com/api/v1
当升级版本的时候,只需要修改配置文件即可。
@DavidNineRoc 第一个确实是个好方法。第二个的话,其实有这样一种情况,就是a接口没有问题,不需要更改,而b接口确迭代了,这个时候就需要a接口的v1版本和b接口的v2版本共存。如果统一改base_url的话,那么a接口也需要做一些改动才行。
@zedisdog 这个问题确实存在,不过你的方式也不能完美解决吧?你的需要加上版本的参数。
@DavidNineRoc 你说的对,确实不能完美解决。
@DavidNineRoc 我们现在的项目,就是按照您所说的。 api 版本区分 和 数据格式返回,都完全一致
请问在lumen里面怎么使用
@clz
lumen
没有具体研究过,这个包目前不支持lumen。不过也是要去通过扩展路由相关的类来实现。laravel
和lumne
路由分发这块应该基本上没有什么区别。@zedisdog 好的,多谢