通过 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
学的 )
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());
}
推荐文章: