基于 OpenApi 3.0 规范的 swagger- PHP 注解路由设计思路,文档路由一步搞定

2018-09-27 02:04:25 +08:00
 doyouhaobaby

路由之路

对于一个框架来说路由是一件非常重要的事情,可以说是框架的核心之一吧。路由的使用便捷和理解复杂度以及性能对整个框架来说至关重要。

Laravel 5

Route::middleware(['first', 'second'])->group(function () {
    Route::get('/', function () {
        // 使用 first 和 second 中间件
    });

    Route::get('user/profile', function () {
        // 使用 first 和 second 中间件
    });
});

TP5

Route::group('blog', function () {
    Route::rule(':id', 'blog/read');
    Route::rule(':name', 'blog/read');
})->ext('html')->pattern(['id' => '\d+', 'name' => '\w+']);

FastRoute

// 匹配 /user/42,不匹配 /user/xyx
$r->addRoute('GET', '/user/{id:\d+}', 'handler');

// 匹配 /user/foobar,不匹配 /user/foo/bar
$r->addRoute('GET', '/user/{name}', 'handler');

// 匹配 /user/foobar,也匹配 /user/foo/bar
$r->addRoute('GET', '/user/{name:.+}', 'handler');

YII2

https://www.yiichina.com/doc/guide/2.0/runtime-routing

上面的这些路由都还不错,用起来很方便,我们是否可以改进一下这种东西呢。

路由设计之简单与严谨

我搬了 4 年多砖,工作中只用过一种框架就是 TP3,我还是很喜欢 TP3 这种方式。我也非常喜欢这种自动映射控制器的路由设计,简洁轻松的感觉。也喜欢用 swagger-php 写写文档,搞搞正则路由。

先看看效果,这是 QueryPHP 框架的路由最终效果:

composer create-project hunzhiwange/queryphp myapp dev-master --repository=https://packagist.laravel-china.org/

php leevel server <Visite http://127.0.0.1:9527/>

Home http://127.0.0.1:9527/
Mvc router http://127.0.0.1:9527/api/test
Mvc restful router http://127.0.0.1:9527/restful/123
Mvc restful router with method http://127.0.0.1:9527/restful/123/show
Annotation router http://127.0.0.1:9527/api/v1/petLeevelForApi/helloworld
Annotation router with bind http://127.0.0.1:9527/api/v2/withBind/foobar

自动路由

很多时候我们不是特别关心它是 GET POST,我们就想简单输入一个地址就可以访问到我们的控制器。

/                               = App\App\Controller\Home::show()
/controller/action     =  App\App\Controller\Controller::action()
/:blog/controller/action =  Blog\App\Controller\Controller::action()
/dir1/dir2/dir3/controller/action = App\App\Controller\Dir1\Dir2\Dir3\Controller::action()

如果 action 对应的类存在,则 action 为 class,入口方法为 handle 或则 run,代码实现. https://github.com/hunzhiwange/framework/blob/master/src/Leevel/Router/Router.php#L598

单元测试用例 https://github.com/hunzhiwange/framework/blob/master/tests/Router/RouterTest.php#L61

上面这种就是一个简单粗暴的路由,简单有效,对很多简单的后台系统非常有效。

自动 restful 路由

restful 已经是一种开发主流,很多路由都在向这一方向靠近,代码实现。 https://github.com/hunzhiwange/framework/blob/master/src/Leevel/Router/Router.php#L541

单元测试 https://github.com/hunzhiwange/framework/blob/master/tests/Router/RouterTest.php#L205

我们访问同一个 url 的时候,根据不同的请求访问不同的后台

/car/5  GET = App\App\Controller\Car::show()
/car/5  POST = App\App\Controller\Car::store()
/car/5  DELETE = App\App\Controller\Car::destroy()
/car/5  PUT = App\App\Controller\Car::update()

restful 路由自动路由也是 pathInfo 一种,我们系统会分析 pathInfo,会将数字类数据扔进 params,其它字符将会合并进行上面的自动路由解析,一旦发现没有 action 将会通过请求方法自动插入请求的 action.

    protected function normalizePathsAndParams(array $data): array
    {
        $paths = $params = [];
        $k = 0;
        foreach ($data as $item) {
            if (is_numeric($item)) {
                $params['_param'.$k] = $item;
                $k++;
            } else {
                $paths[] = $item;
            }
        }
        return [
            $paths,
            $params,
        ];
    }

贴心转换

/he_llo-wor/Bar/foo/xYY-ac/controller_xx-yy/action-xxx_Yzs => App\App\Controller\HeLloWor\Bar\Foo\XYYAc\ControllerXxYy::actionXxxYzs()

单元测试 https://github.com/hunzhiwange/framework/blob/master/tests/Router/RouterTest.php#L156

OpenApi 3.0 规范的 swagger-php 注解路由

上面是一种预热,我们的框架路由设计是这样,优先进行 pathinfo 解析,如果解析失败将进入注解路由高级解析阶段。

预警:注解路由匹配比较复杂,单元测试 100% 覆盖 https://github.com/hunzhiwange/framework/blob/master/tests/Router/RouterAnnotationTest.php

http://127.0.0.1:9527/api  = openapi 3
http://127.0.0.1:9527/apis/  = swagger-ui
http://127.0.0.1:9527/api/v1/petLeevelForApi/helloworld = 路由

swagger-ui

swagger-php 3 注释生成的 openapi 3

路由结果

http://127.0.0.1:9527/api/v1/petLeevelForApi/helloworld
Hi you,i am petLeevelForApi and it petId is helloworld

控制器代码 https://github.com/hunzhiwange/queryphp/blob/master/application/app/App/Controller/Petstore/Api.php#L27

在工作大量使用 swagger-php 来生成注释文档,swagger 有 GET,POST,没错它就是路由,既然如此我们何必在定义一个 router.php 来搞路由。

      /**
     * @OA\Get(
     *     path="/api/v1/petLeevelForApi/{petId:[A-Za-z]+}/",
     *     tags={"pet"},
     *     summary="Just test the router",
     *     operationId="petLeevelForApi",
     *     @OA\Parameter(
     *         name="petId",
     *         in="path",
     *         description="ID of pet to return",
     *         required=true,
     *         @OA\Schema(
     *             type="integer",
     *             format="int64"
     *         )
     *     ),
     *     @OA\Response(
     *         response=405,
     *         description="Invalid input"
     *     ),
     *     security={
     *         {"petstore_auth": {"write:pets", "read:pets"}}
     *     },
     *     leevelParams={"args1": "hello", "args2": "world"}
     * )
     *
     * @param mixed $petId
     */
    public function petLeevelForApi($petId)
    {
        return sprintf('Hi you,i am petLeevelForApi and it petId is %s', $petId);
    }
   
VS

    Route::get('/', function () {
        // 使用 first 和 second 中间件
    });

QueryPHP 的注解路由,在标准 swagger-php 的基础上增加了自定义属性扩展功能

单条路由

leevelScheme="https",
leevelDomain="{subdomain:[A-Za-z]+}-vip.{domain}",
leevelParams={"args1": "hello", "args2": "world"},
leevelMiddlewares="api"
leevelBind="\XXX\XXX\class@method"

leevelBind 未设置自动绑定当前 class 和方法,如果注释写到控制器上,也可以放到空文件等地方,这个时候没有上下文方法和 class,需要绑定 leevelBind leevelBind 可以绑定 class (默认方法 handle or run ),通过 @ 自定义

地址支持正则参数

/api/v1/petLeevelForApi/{petId:[A-Za-z]+}/

分组路由支持

基于 leevelGroup 自定义属性支持

 * @OA\Tag(
 *     name="pet",
 *     leevelGroup="pet",
 *     description="Everything about your Pets",
 *     @OA\ExternalDocumentation(
 *         description="Find out more",
 *         url="http://swagger.io"
 *     )
 * )
 * @OA\Tag(
 *     name="store",
 *     leevelGroup="store",
 *     description="Access to Petstore orders",
 * )
 * @OA\Tag(
 *     name="user",
 *     leevelGroup="user",
 *     description="Operations about user",
 *     @OA\ExternalDocumentation(
 *         description="Find out more about store",
 *         url="http://swagger.io"
 *     )
 * )

全局设置

 * @OA\ExternalDocumentation(
 *     description="Find out more about Swagger",
 *     url="http://swagger.io",
 *     leevels={
 *         "*": {
 *             "middlewares": "common"
 *         },
 *         "foo/*world": {
 *             "middlewares": "custom"
 *         },
 *         "api/test": {
 *             "middlewares": "api"
 *         },
 *         "/api/v1": {
 *             "middlewares": "api",
 *             "group": true
 *         },
 *         "api/v2": {
 *             "middlewares": "api",
 *             "group": true
 *         },
 *         "/web/v1": {
 *             "middlewares": "web",
 *             "group": true
 *         },
 *         "web/v2": {
 *             "middlewares": "web",
 *             "group": true
 *         }
 *     }
 * )

使用 php leevel router:cache 生成路由缓存 runtime/bootstrap/router.php

我写一个解析模块来生成路由,这就是我们真正的路由,一个基于标准 swagger-php 的注解路由。

https://github.com/hunzhiwange/framework/blob/master/src/Leevel/Router/OpenApiRouter.php

<?php /* 2018-09-26 19:00:27 */ ?>
<?php return array (
  'base_paths' => 
  array (
    '*' => 
    array (
      'middlewares' => 
      array (
        'handle' => 
        array (
        ),
        'terminate' => 
        array (
          0 => 'Leevel\\Log\\Middleware\\Log@terminate',
        ),
      ),
    ),
    '/^\\/foo\\/(\\S+)world\\/$/' => 
    array (
      'middlewares' => 
      array (
      ),
    ),
    '/^\\/api\\/test\\/$/' => 
    array (
      'middlewares' => 
      array (
        'handle' => 
        array (
          0 => 'Leevel\\Throttler\\Middleware\\Throttler@handle:60,1',
        ),
        'terminate' => 
        array (
        ),
      ),
    ),
  ),
  'group_paths' => 
  array (
    '/api/v1' => 
    array (
      'middlewares' => 
      array (
        'handle' => 
        array (
          0 => 'Leevel\\Throttler\\Middleware\\Throttler@handle:60,1',
        ),
        'terminate' => 
        array (
        ),
      ),
    ),
    '/api/v2' => 
    array (
      'middlewares' => 
      array (
        'handle' => 
        array (
          0 => 'Leevel\\Throttler\\Middleware\\Throttler@handle:60,1',
        ),
        'terminate' => 
        array (
        ),
      ),
    ),
    '/web/v1' => 
    array (
      'middlewares' => 
      array (
        'handle' => 
        array (
          0 => 'Leevel\\Session\\Middleware\\Session@handle',
        ),
        'terminate' => 
        array (
          0 => 'Leevel\\Session\\Middleware\\Session@terminate',
        ),
      ),
    ),
    '/web/v2' => 
    array (
      'middlewares' => 
      array (
        'handle' => 
        array (
          0 => 'Leevel\\Session\\Middleware\\Session@handle',
        ),
        'terminate' => 
        array (
          0 => 'Leevel\\Session\\Middleware\\Session@terminate',
        ),
      ),
    ),
  ),
  'groups' => 
  array (
    0 => '/pet',
    1 => '/store',
    2 => '/user',
  ),
  'routers' => 
  array (
    'get' => 
    array (
      'p' => 
      array (
        '/pet' => 
        array (
          '/api/v1/petLeevelForApi/{petId:[A-Za-z]+}/' => 
          array (
            'params' => 
            array (
              'args1' => 'hello',
              'args2' => 'world',
            ),
            'bind' => '\\App\\App\\Controller\\Petstore\\Api@petLeevelForApi',
            'var' => 
            array (
              0 => 'petId',
            ),
          ),
          '/api/v2/petLeevel/{petId:[A-Za-z]+}/' => 
          array (
            'scheme' => 'https',
            'domain' => '{subdomain:[A-Za-z]+}-vip.{domain}.queryphp.cn',
            'params' => 
            array (
              'args1' => 'hello',
              'args2' => 'world',
            ),
            'middlewares' => 
            array (
              'handle' => 
              array (
                0 => 'Leevel\\Throttler\\Middleware\\Throttler@handle:60,1',
              ),
              'terminate' => 
              array (
              ),
            ),
            'bind' => '\\App\\App\\Controller\\Petstore\\Pet@petLeevel',
            'domain_regex' => '/^([A-Za-z]+)\\-vip\\.(\\S+)\\.queryphp\\.cn$/',
            'domain_var' => 
            array (
              0 => 'subdomain',
              1 => 'domain',
            ),
            'var' => 
            array (
              0 => 'petId',
            ),
          ),
          '/pet/{petId}/' => 
          array (
            'bind' => '\\App\\App\\Controller\\Petstore\\Pet@getPetById',
            'var' => 
            array (
              0 => 'petId',
            ),
          ),
          '/web/v1/petLeevelForWeb/{petId:[A-Za-z]+}/' => 
          array (
            'bind' => '\\App\\App\\Controller\\Petstore\\Web@petLeevelForWeb',
            'var' => 
            array (
              0 => 'petId',
            ),
          ),
          'regex' => 
          array (
            0 => '~^(?|/api/v1/petLeevelForApi/([A-Za-z]+)/|/api/v2/petLeevel/([A-Za-z]+)/()|/pet/(\\S+)/()()|/web/v1/petLeevelForWeb/([A-Za-z]+)/()()())$~x',
          ),
          'map' => 
          array (
            0 => 
            array (
              2 => '/api/v1/petLeevelForApi/{petId:[A-Za-z]+}/',
              3 => '/api/v2/petLeevel/{petId:[A-Za-z]+}/',
              4 => '/pet/{petId}/',
              5 => '/web/v1/petLeevelForWeb/{petId:[A-Za-z]+}/',
            ),
          ),
        ),
      ),
      'static' => 
      array (
        '/api/v2/petLeevelV2Api/' => 
        array (
          'bind' => '\\App\\App\\Controller\\Petstore\\Api@petLeevelV2ForApi',
        ),
        '/pet/findByTags/' => 
        array (
          'bind' => '\\App\\App\\Controller\\Petstore\\Pet@findByTags',
        ),
        '/store/' => 
        array (
          'bind' => '\\App\\App\\Controller\\Petstore\\Store@getInventory',
        ),
        '/user/login/' => 
        array (
          'bind' => '\\App\\App\\Controller\\Petstore\\User@loginUser',
        ),
        '/user/logout/' => 
        array (
          'bind' => '\\App\\App\\Controller\\Petstore\\User@logoutUser',
        ),
        '/web/v2/petLeevelV2Web/' => 
        array (
          'bind' => '\\App\\App\\Controller\\Petstore\\Web@petLeevelV2ForWeb',
        ),
      ),
      'w' => 
      array (
        '_' => 
        array (
          '/api/v2/withBind/{petId:[A-Za-z]+}/' => 
          array (
            'bind' => '\\App\\App\\Controller\\Petstore\\Pet@withBind',
            'middlewares' => 
            array (
              'handle' => 
              array (
                0 => 'Leevel\\Throttler\\Middleware\\Throttler@handle:60,1',
              ),
              'terminate' => 
              array (
              ),
            ),
            'var' => 
            array (
              0 => 'petId',
            ),
          ),
          'regex' => 
          array (
            0 => '~^(?|/api/v2/withBind/([A-Za-z]+)/)$~x',
          ),
          'map' => 
          array (
            0 => 
            array (
              2 => '/api/v2/withBind/{petId:[A-Za-z]+}/',
            ),
          ),
        ),
      ),
      's' => 
      array (
        '/store' => 
        array (
          '/store/order/{orderId}/' => 
          array (
            'bind' => '\\App\\App\\Controller\\Petstore\\Store@getOrderById',
            'var' => 
            array (
              0 => 'orderId',
            ),
          ),
          'regex' => 
          array (
            0 => '~^(?|/store/order/(\\S+)/)$~x',
          ),
          'map' => 
          array (
            0 => 
            array (
              2 => '/store/order/{orderId}/',
            ),
          ),
        ),
      ),
      'u' => 
      array (
        '/user' => 
        array (
          '/user/{username}/' => 
          array (
            'bind' => '\\App\\App\\Controller\\Petstore\\User@getUserByName',
            'var' => 
            array (
              0 => 'username',
            ),
          ),
          'regex' => 
          array (
            0 => '~^(?|/user/(\\S+)/)$~x',
          ),
          'map' => 
          array (
            0 => 
            array (
              2 => '/user/{username}/',
            ),
          ),
        ),
      ),
    ),
    'post' => 
    array (
      'static' => 
      array (
        '/pet/' => 
        array (
          'bind' => '\\App\\App\\Controller\\Petstore\\Pet@addPet',
        ),
        '/store/order/' => 
        array (
          'bind' => '\\App\\App\\Controller\\Petstore\\Store@placeOrder',
        ),
        '/user/' => 
        array (
          'bind' => '\\App\\App\\Controller\\Petstore\\User@createUser',
        ),
        '/user/createWithArray/' => 
        array (
          'bind' => '\\App\\App\\Controller\\Petstore\\User@createUsersWithListInput',
        ),
      ),
      'p' => 
      array (
        '/pet' => 
        array (
          '/pet/{petId}/' => 
          array (
            'bind' => '\\App\\App\\Controller\\Petstore\\Pet@updatePetWithForm',
            'var' => 
            array (
              0 => 'petId',
            ),
          ),
          '/pet/{petId}/uploadImage/' => 
          array (
            'bind' => '\\App\\App\\Controller\\Petstore\\Pet@uploadFile',
            'var' => 
            array (
              0 => 'petId',
            ),
          ),
          'regex' => 
          array (
            0 => '~^(?|/pet/(\\S+)/|/pet/(\\S+)/uploadImage/())$~x',
          ),
          'map' => 
          array (
            0 => 
            array (
              2 => '/pet/{petId}/',
              3 => '/pet/{petId}/uploadImage/',
            ),
          ),
        ),
      ),
    ),
    'delete' => 
    array (
      'p' => 
      array (
        '/pet' => 
        array (
          '/pet/{petId}/' => 
          array (
            'bind' => '\\App\\App\\Controller\\Petstore\\Pet@deletePet',
            'var' => 
            array (
              0 => 'petId',
            ),
          ),
          'regex' => 
          array (
            0 => '~^(?|/pet/(\\S+)/)$~x',
          ),
          'map' => 
          array (
            0 => 
            array (
              2 => '/pet/{petId}/',
            ),
          ),
        ),
      ),
      's' => 
      array (
        '/store' => 
        array (
          '/store/order/{orderId}/' => 
          array (
            'bind' => '\\App\\App\\Controller\\Petstore\\Store@deleteOrder',
            'var' => 
            array (
              0 => 'orderId',
            ),
          ),
          'regex' => 
          array (
            0 => '~^(?|/store/order/(\\S+)/)$~x',
          ),
          'map' => 
          array (
            0 => 
            array (
              2 => '/store/order/{orderId}/',
            ),
          ),
        ),
      ),
      'u' => 
      array (
        '/user' => 
        array (
          '/user/{username}/' => 
          array (
            'bind' => '\\App\\App\\Controller\\Petstore\\User@deleteUser',
            'var' => 
            array (
              0 => 'username',
            ),
          ),
          'regex' => 
          array (
            0 => '~^(?|/user/(\\S+)/)$~x',
          ),
          'map' => 
          array (
            0 => 
            array (
              2 => '/user/{username}/',
            ),
          ),
        ),
      ),
    ),
  ),
); ?>

路由匹配

有了路由,当然就是路由匹配问题,我们在路由中参考了 composer 第一个字母分组,分组路由分组,以及基于 fastrouter 合并路由正则分组,大幅度提高了路由匹配性能。

https://github.com/hunzhiwange/framework/blob/master/src/Leevel/Router/Match/Annotation.php

其中 fastRouter 实现代码如下: https://github.com/hunzhiwange/framework/blob/master/src/Leevel/Router/Match/Annotation.php#L162 https://github.com/hunzhiwange/framework/blob/master/src/Leevel/Router/OpenApiRouter.php#L259

路由文档一步搞定就是这么简单,它和 lavarel 等框架的路由一样强大,没有很多记忆的东西,强制你使用 swagger,写路由写文档。

又简单又严谨

路由组件 100% 单元测试覆盖。 QueryPHP 路由已经全部完结,目前剩余 55% 数据库层和 auth 目前的单元测试,QueryPHP 全力编写单元测试做为重构,即将上线 alpah 版本。八年磨一剑,只珍朝夕。我们只想为中国 PHP 业界提供 100% 单元测试覆盖的框架竟在 QueryPHP.

官网重新出发 https://www.queryphp.com/

7920 次点击
所在节点    PHP
3 条回复
doyouhaobaby
2018-09-27 02:10:44 +08:00
时间比较仓促,睡觉,明天要搬砖。
linxl
2018-09-27 09:10:18 +08:00
弱弱的问一下,写注释不会写疯吗
doyouhaobaby
2018-09-27 09:21:15 +08:00
@linxl 需求分析完,都是后端先写文档,然后一起讨论是否合理,然后 easymock 基于生成的 swagger json 来生成 mock 数据,前端分开做,最后一起联调。实际上写文档是必须的,复制粘贴改改就行。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/492979

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX