通过 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\TestControllerApp\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::$routesIlluminate\Routing\RouteCollection
  • Illuminate\Routing\RouteCollection 中, $allRoutes 保存了所有的路由。另外,所有路由被根据路由名和控制器来索引,分别又放到另外两个成员变量中: $nameList$actionList ,应该是为了方便查找。

实现

这次的思想是:在所有路由读取出来以后,想办法将路由根据 version 分组。然后把最开始解析路由的逻辑改成根据 header 中的 version 字段来解析。

Dezsidog\Routing\Route

首先是最基本的单元 Route ,扩展 Illuminate\Routing\Route ,给 action 添加一个 version 字段,并且设置 settergetter

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 ,添加一个根据版本分发路由的方法。要做的就是,根据 versionDezsidog\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\Kerneldingo/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());
    }
本帖已被设为精华帖!
本帖由系统于 5年前 自动加精
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
讨论数量: 20

在这里请教一下。一般写RESTful api的时候 返回的数据格式需要在外面包裹一层吗?
比如传统我们写是

{
    "code": 0,
   "msg": "success",
    "data": {
    }
}

但是最近看了一些api的demo发现部分是直接返回了data字段里的数据,并没有包裹。而客户端是通过http status code来判断的,并不是code这个字段。

但是呢,貌似在错误的时候,又会返回一个error_code类似的字段来说明错误。想请教下目前主流的是不是都这么弄?

5年前 评论

@keer 通过 http status code 来判断是标准的做法,不过要不要这样做,具体还是看需求和开发成本。虽然与接口对接的其他系统肯定都能通过判断你给的code来完成业务逻辑,但如果你一开始就返回 404 可能有些系统就能少执行一部分代码。另外, http status code 就只有那么些,肯定没法表示系统中所有的错误,比如, 400这个错误,很可能表示很多种错误。于是才会有error_code这种解决方式。通常我更倾向于使用 http status code ,有时也会因为这个被其他人怼,哈哈哈哈。

5年前 评论

@zedisdog 感谢。确实有团队的开发成本。上周和前端沟通了下使用这个http status code 可是貌似对这个并不敏感。可能会增加前端或者app开发者的学习成本。但是我又想使用标准的规范(毕竟我也没写过这种,所以想尝试下)。纠结。。。

可能还是后期开会在讨论了。 :joy:

5年前 评论

@keer app那块可能确实会有点麻烦,不过前端还好啦。前端Promise里面的 thencatch 就是一个例子, thenhttp status code 在200到299这个范围(也就是成功)执行的,而 catch 就是大于等于 400 会执行的。另外,jquery里面的ajax也会有 successerror 这两个回调可以使用,原理也是一样的。

5年前 评论

@carlclone 我也不想折腾,有好的建议,欢迎指教 :smile:

5年前 评论
Atzcl

hi,根据文章一步一步做,在往 AppServiceProviderregister 加载

$this->app->singleton('router', function ($app) {
    return new Router($app['events'], $app);
 });

会导致框架加载超时。。

另外尝试使用你的 composer 包来实现,也是失败,,

本地环境:mac + php 7.2.5 + Nginx 1.13.12 + Laravl 5.5.28

5年前 评论

@Atzcl 你好,我只看到有5.5.28,没看到5.5.40,不过问题已经修复了,请更新这个包。非常感谢你的反馈。

5年前 评论
ThinkQ

很好!

5年前 评论
Atzcl

@zedisdog Ok,感谢~

5年前 评论

@Atzcl 这篇帖子我也改过了,如果喜欢折腾,可以再自己做一个,哈哈哈

5年前 评论

状态码的话我比较喜欢全部统一返回数据格式

{
    "code": 0,
   "msg": "success",
    "data": {
    }
}

服务器把 500,404 等等错误也捕获,然后也通过 code 返回状态码,这样子前端比较舒服点。


至于 api 版本,现在的前端都有设置一个base_url,这样的时候,只需要在配置文件写,http://domain.com/api/v1

Route::prefix('v1')->group(function () {
    Route::get('users', function () {
        // Matches The "/v1/users" URL
    });
});

当升级版本的时候,只需要修改配置文件即可。

5年前 评论

@DavidNineRoc 第一个确实是个好方法。第二个的话,其实有这样一种情况,就是a接口没有问题,不需要更改,而b接口确迭代了,这个时候就需要a接口的v1版本和b接口的v2版本共存。如果统一改base_url的话,那么a接口也需要做一些改动才行。

5年前 评论

@zedisdog 这个问题确实存在,不过你的方式也不能完美解决吧?你的需要加上版本的参数。

5年前 评论

@DavidNineRoc 你说的对,确实不能完美解决。

5年前 评论
Aaron

@DavidNineRoc 我们现在的项目,就是按照您所说的。 api 版本区分 和 数据格式返回,都完全一致

5年前 评论

请问在lumen里面怎么使用

5年前 评论

@clz lumen 没有具体研究过,这个包目前不支持lumen。不过也是要去通过扩展路由相关的类来实现。laravellumne 路由分发这块应该基本上没有什么区别。

5年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!