V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
doyouhaobaby
V2EX  ›  PHP

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

  •  1
     
  •   doyouhaobaby · 2018-09-27 02:04:25 +08:00 · 7900 次点击
    这是一个创建于 2028 天前的主题,其中的信息可能已经有所发展或是发生改变。

    avatar

    路由之路

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

    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

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

    avatar

    自动 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()
    

    avatar

    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

    avatar

    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 avatar

    swagger-php 3 注释生成的 openapi 3 avatar

    路由结果

    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/

    avatar

    3 条回复    2018-09-27 09:21:15 +08:00
    doyouhaobaby
        1
    doyouhaobaby  
    OP
       2018-09-27 02:10:44 +08:00
    时间比较仓促,睡觉,明天要搬砖。
    linxl
        2
    linxl  
       2018-09-27 09:10:18 +08:00
    弱弱的问一下,写注释不会写疯吗
    doyouhaobaby
        3
    doyouhaobaby  
    OP
       2018-09-27 09:21:15 +08:00
    @linxl 需求分析完,都是后端先写文档,然后一起讨论是否合理,然后 easymock 基于生成的 swagger json 来生成 mock 数据,前端分开做,最后一起联调。实际上写文档是必须的,复制粘贴改改就行。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   4279 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 32ms · UTC 05:26 · PVG 13:26 · LAX 22:26 · JFK 01:26
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.