基于think-orm的强大的API文档自动生成器

作为一名PHP后端开发工程师,提供给前端接口是必须的,但是作为程序员我们最痛恨自己写文档,最恨别人不写文档。对于开发接口的人来说,接口文档确实必要写,但是有没有简化点的方式呢,自己几款API专用的软件,比如POSTMAN,APIPOST,这些软件在api接口发出请求方面是非常好用的,但是普遍对于文档的生成还是没有支持的太好,繁琐复杂。下面从分析自己接触的文档自动生成分析利弊,然后阐明自己观点,并介绍自己是如何实现的。

框架仓库地址,欢迎体验
https://github.com/xiaobai1993/fast-think

1.文档生成功能如何设计?

文档生成器,应该包含请求地址,支持的请求参数,请求参数的意义,响应的内容,响应的内容的每个字段的含义。对于除了响应内容的每个字段的含义以外的功能,有很多成熟的解决方案,比如postman这类工具可以自动生成,稍微添加点注释就可以,还有通过对控制器的函数进行注释,通过反射解析注释的。但是对于如何标记响应的每个字段,很多时候需要人工去做,过于麻烦。

最理想的开发情况是,我们自己开发接口,自己通过一些API工具测试,比如就拿搜索接口来说,我们假定支持5个条件,我们一定是把这5个条件都加上,并且得到预期数据的时候,才可以说,请基于现在的情况生成文档吧。确切的说就是需要在一个自己满意的api测试情景下,根据当次的请求和响应生成文档,才是满意的,假设需求有变化的时候,我可以立即要求重新生成。这就是一个比较理想的使用场景。

2.如何设计实现

背景: 公司使用thinkphp开发,普遍使用了orm关联 ,并且对代码风格进行了统一的封装,可以用模板生成器生成基本业务代码。

目标:实现根据每次的请求和响应生成最新文档,包括对返回的结果进行文档注释。

2.1 生成文档请求地址和请求类型

这一步非常简单,调用Request的方法就可以实现。

Request::baseUrl(); //文档的地址
Request::method();//请求类型
2.2 接口的业务功能和含义描述
image.png

这一部也不难,根据Request的controller和action,是有办法确定具体控制器对应方法的,可以获取注释内容,比如上面例子,人物列表页就是业务功能,根据条件搜索人物信息就是描述,具体怎么实现看个人设计。

2.3 获取文档支持的请求参数。

这一步也简单,但是生成请求参数的描述有点麻烦。

 $param = Request::param();

对于请求的参数,生成文档需要加说明如下。

参数名称 是否必填 参数类型 案例 说明
  • 参数名称:为自己调试的参数名称,
  • 是否必填:这个我们可以在文档生成后根据业务根据需要修改下,这里可以根据接口的类型合理的设置默认值,
  • 参数类型:根据自己测试用的数据,大多数场景无非就是Number,String,Array。
  • 案例:就是自己提交的字段对应的值。
  • 说明:对于每个接口提交的参数,我们都可以用一个具体Param类来接收,分别和这些类的属性对应。然后我们可以在控制器里面根据方法的所引用的Param,反射Param类属性对应的注释,作为说明的内容,否则就标记为不明确。

比如下面这个方法是添加产品的方法。

 /**
     * 添加项目产品
     * @param Request $request
     * @return \think\Response\Json
     */
    public function create(Request $request)
    {
        ProjectProductValidate::getInstance()->goCheck();
        $result = ProjectProductService::getInstance()->create(
            ProjectProductParam::create($request->param())
        );
        return $this->json(['id' => $result]);
    }

下面这个思路非常有用,后面也会重复使用

在第二部已经可以明确知道具体控制器对应类和方法了,php的反射可以精准的获取到方法出现在文件的起始行和结束行。就是上面这段代码是可以精准的从文件中读取到的,在代码风格统一的情况下,可以用正则匹配到ProjectProductParam,然后在控制器文件中在进行一次正则匹配就能够得到ProjectProductParam所在的完整命名空间,从而实例化这个类,并得到请求参数名字对应的属性的注释。

2.4 获取返回结果的案例

难度为零,就是本次请求的响应结果。

2.5 为请求的返回结果增加注释

这一步是难度最大的。因为大部分返回的数据的字段都可以认为是从数据表获取的,因为我们在开发中大量使用了ORM,我们是可以生成注释的。因为风格统一,比如对于关联的定义我都以Data结尾,所以会更方便一些。

    /**
     * 融资数据
     * @return \think\model\relation\HasMany
     */
    public function fundingData()
    {
        return $this->hasMany(CompanyFundingDetailModel::class,'company_guid','guid');
    }

举例子来说:获取项目列表

一个项目属于一家公司,公司有所在城市,存放的是地区表的id。我要在项目列表中,获取一些项目的基本信息,和公司的名字,所在位置。

对于thinkphp的ORM代码定义上面关系如下

ProjectModel

   /**
     * 项目对应的公司数据
     * @return \think\model\relation\HasOne
     */
    public function companyData()
    {
        return $this->hasOne(CompanyModel::class,'guid','company_guid');
    }

CompanyModel

   /**
     * 公司所在的市信息
     * @return \think\model\relation\HasOne
     */
    public function cityData()
    {
        return $this->hasOne(DistrictModel::class,'id','city_id');
    }

一条数据如下

{
      "guid":1,
       "name":"项目名字",
       "company_guid":2,
       "company_data":{
             "guid":2,
              "city_id":3,
              "city_data":{
                    "id":3,
                    "name":"保定"
              }   
        } 
}

策略:

  1. 对于最外面一层的信息,通过在控制器里面获取到对应service,可以获取到service关联的模型,根据模型可以获取到数据表的创建语句信息,然后解析语句中包含的注释,可以获取到对应的值的含义。不能匹配到标记为不明确,如果模型没有获取到就不必再匹配了。
  2. 对于以data结尾的,可以在模型中找是否存在方法名,存在方法就获取方法的注释,以data结尾的如果是一对一就是关联数组,否则就是索引数组,这时可以取数组中的第一个。作为将要递归循环的数组,同样将该方法中所使用的模型用正则匹配出来,比如解析company_data的时候要匹配出,里面用的是CompanyModel,然后根据这两项重复第一步。

细节:
1.判断一个方法存不存在,method_exist在这里不准确,因为这里只是希望看到某个模型文件中定义的关联方法,所以需要手动的做一次方法名在类所在的文件名的匹配,否则可能会把thinkphp自带的Model的方法也算上,造成统计错误。
2.对于append的属性,需要自己想办法匹配getXXXattr方法。
3.很多地方需要驼峰和下划线的转换。

  1. 如何灵活使用?自己定义了一个特殊的参数,比如如果请求参数中包含make_doc=1就生成文档。可以根据请求url创建对应的目录,保存到对应文件,一个文件对应一个接口。所以以后在需要统一生成最新文档的时候,只需要将所有的api统一带上参数,统一发一次请求就生成了。这个功能很容易实现,可以自己写脚本,也可以用api工具。
 if (Request::param(AutoDocument::$flag_field) == 1) {
            (new AutoDocument())->createDocument($outPutData);//处理返回的数据
        }
        return \think\response\Json::create($outPutData);//返回数据

效果演示。

下面是本地带着参数请求接口,直接生成的文件内容,因为我们的文档也是markdown,放在一块有些乱,就单独放链接了。
https://www.jianshu.com/p/bc54478b9609

个人感想

人生苦短,我们不应该做太多重复没有意义的劳动,希望此篇文档能够给更多遭受写接口文档痛苦的人带来启发,结合所在公司实际业务场景,也开发出灵活强大的文档自动生成器。

实现源码

<?php

namespace app\common\lib;

use app\common\helper\ArrayHelper;
use think\Db;
use think\facade\Request;
use think\Loader;
use think\Model;

class AutoDocument
{

    static $flag_field = "make_doc";

    protected $classFileMaps = [];

    const noFound = '`不确定`';
    /**
     * api文档的格式
     * @var string
     */
    public static $template = '## {{api_name}}
### 业务功能
{{api_desc}}

### 接口地址
`{{api_url}}`

### 请求参数
参数名称 |是否必填 |参数类型 | 案例 | 说明
:--- | :---: | :---: | :--- | :---
{{ask_param_desc}}

### 返回数据

{{response}}


参数名称 | 参数类型 | 说明
:--- | :---: | :---
{{response_desc}}';

    /**
     * 常用的注释
     */
    const whiteLists = [
        'code' => '编码',
        'total' => '总数',
        'msg' => '提示信息',
        'last_page' => '最后页码',
        'current_page' => '当前页',
        'data' => '数据',
        'per_page' => '每页大小'
    ];

    public function __construct()
    {
        $this->classFileMaps = [];
    }

    public function createDocument($outPutData)
    {
        $vars = [];
        $template = self::$template;
        $vars['api_url'] = Request::baseUrl();
        $controller = Request::controller();
        $module = Request::module();
        $className = Loader::parseName(str_replace('.', '\\', 'app\\' . $module . '\\controller\\' . $controller), 1);
        $vars['class'] = $className;
        $vars['class_exist'] = class_exists($className);
        $method = Request::action();
        $vars['action'] = $method;
        $classReflect = new \ReflectionClass($vars['class']);
        $methodAction = $classReflect->getMethod($vars['action']);
        $vars['api_name'] = $this->getDocTitle($methodAction->getDocComment());
        $vars['api_desc'] = $this->getDocBody($methodAction->getDocComment());
        $model = $this->getModel($methodAction);
        $param = Request::param();
        $paramInfo = [];
        $paramKeys = array_keys($param);
        foreach ($param as $key => $value) {
            if ($key == self::$flag_field) {
                continue;
            }
            $oneItem = [];
            $oneItem['param_name'] = $key;
            if ($paramKeys[0] == $key) {
                $oneItem['is_must'] = 'Y';
            } else {
                $oneItem['is_must'] = 'N';
            }
            $oneItem['param_type'] = $this->getValueType($value);
            $oneItem['param_example'] = "例如:`$value`";
            $oneItem['param_desc'] = $this->getKeyDesc($key, $model);
            $paramInfo[] = $oneItem;
        }
        $vars['ask_param_desc'] = $paramInfo;
        $vars['response'] = json_decode(json_encode($outPutData, JSON_UNESCAPED_UNICODE), JSON_UNESCAPED_UNICODE);
        $comments = [];
        $this->getResponseComment($vars['response'], $model, $comments);
        $vars['response_desc'] = $comments;
        $this->assignVars($vars, $template);
        return $vars;
    }

    protected function assignVars($vars, $template)
    {
        $template = str_replace("{{api_name}}", $vars['api_name'], $template);
        $template = str_replace("{{api_desc}}", $vars['api_desc'], $template);
        $template = str_replace("{{api_url}}", $vars['api_url'], $template);
        $ask_param_desc = [];
        foreach ($vars['ask_param_desc'] as $var) {
            $ask_param_desc[] = implode("|", array_values($var));
        }
        $ask_param_desc = implode("\n", $ask_param_desc);
        $template = str_replace("{{ask_param_desc}}", $ask_param_desc, $template);
        $template = str_replace("{{response}}", json_encode($vars['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), $template);
        $response_desc = [];
        foreach ($vars['response_desc'] as $var) {
            $response_desc[] = implode("|", array_values($var));
        }
        $response_desc = implode("\n", $response_desc);
        $template = str_replace("{{response_desc}}", $response_desc, $template);
        file_put_contents('comment.txt', $template);
    }

    /**
     * 获取数据的类型
     * @param $value
     * @return string
     */
    public function getValueType($value)
    {
        if (is_numeric($value)) {
            return "Number";
        } elseif (is_string($value)) {
            return "String";
        } elseif (is_array($value)) {
            if (ArrayHelper::isIndexed($value)) {
                return "Array";
            } else {
                return "Object";
            }
        } elseif ($value === null) {
            return "Object";
        } else {
            return self::noFound;
        }
    }

    /**
     * 获取字段的注释
     * @param $key
     * @param Model|null $model
     * @return string
     */
    public function getKeyDesc($key, Model $model = null, $keyPrefix = '')
    {
        if (in_array($key, array_keys(self::whiteLists)) && !$keyPrefix) {
            return self::whiteLists[$key];
        }
        if (!$model) {
            return self::noFound;
        }
        if (strpos($key, 'search_') !== false) {
            $key = substr($key, 7);
        }
        $methodName = Loader::parseName($key, 1, false);
        $modelClass = new \ReflectionClass(get_class($model));
        $attrMethodName = 'get' . Loader::parseName($key, 1) . "Attr";
        if (strpos($key, '_data')) {
            if (!$this->checkMethodExist($model,$key)){
                return self::noFound;
            }
            $method = $modelClass->getMethod($methodName);
            if ($method) {
                return $this->getDocTitle($method->getDocComment());
            } else {
                return self::noFound;
            }
        } elseif (method_exists($model, $attrMethodName)) {
            $method = $modelClass->getMethod($attrMethodName);
            $doc = $this->getDocTitle($method->getDocComment());
            if ($doc) {
                return $doc;
            } else {
                if (strpos(strtolower($key), 'full_path')) {
                    return $this->getKeyDesc(substr($key, 0, strlen($key) - strlen('full_path'))) . "的全路径,用来展示";
                } elseif (strpos(strtolower($key), 'for_display')) {
                    return $this->getKeyDesc(substr($key, 0, strlen($key) - strlen('full_path'))) . "对应的显示时间戳";
                }
            }
        } else {
            $fieldMaps = $this->getTableDocument($model);
            if (!isset($fieldMaps[$key]) && strpos($key, 'id') !== false) {
                return $key;
            }
            return $fieldMaps[$key]??self::noFound;
        }
    }

    /**
     * 根据控制器和方法名字获取service
     * @param \ReflectionMethod $method
     * @return null|Model
     */
    public function getModel(\ReflectionMethod $method)
    {
        $fileName = $method->getFileName();
        $startLine = $method->getStartLine();
        $endLine = $method->getEndLine();
        $methodContent = $this->readFile($fileName, $startLine, $endLine);
        //找service
        if (preg_match('/([\S]*?Service)\:\:getInstance/', $methodContent, $matches)) {
            $service = trim($matches[1]);
            $fileContent = $this->getClassFileContent($method->class);
            if (preg_match("#use\s*(app.*?$service)#", $fileContent, $matches)) {
                $serviceClass = $matches[1];
                if (class_exists($serviceClass)) {
                    $service = new $serviceClass();
                    if ($service instanceof BaseService) {
                        return $service->getModel();
                    } else {
                        return null;
                    }
                } else {
                    return null;
                }
            } else {
                return null;
            }
        } else {
            return null;
        }
    }

    /**
     * 读文件
     * @param $file_name
     * @param $start
     * @param $end
     * @return string
     */
    function readFile($file_name, $start, $end)
    {
        $limit = $end - $start;
        $f = new \SplFileObject($file_name, 'r');
        $f->seek($start);
        $ret = "";
        for ($i = 0; $i < $limit; $i++) {
            $ret .= $f->current();
            $f->next();
        }
        return $ret;
    }


    /**
     * 获取类或者方法注释的标题,第一行
     * @param $docComment
     * @return string
     */
    protected function getDocTitle($docComment)
    {
        if ($docComment !== false) {
            $docCommentArr = explode("\n", $docComment);
            $comment = trim($docCommentArr[1]);
            return trim(substr($comment, strpos($comment, '*') + 1));
        }
        return '';
    }

    /**
     * 获取方法的描述的主题,不包括标题
     * @param $docComment
     * @return string
     */
    protected function getDocBody($docComment)
    {
        if ($docComment !== false) {
            $docCommentArr = explode("\n", $docComment);
            $comment = implode_ids(array_slice($docCommentArr, 2), "\n");
            $comment = preg_replace("#^([\s\S]*?)@[\s\S]*$#", "$1", $comment);
            $comment = str_replace("*", "", $comment);
            return trim(substr($comment, strpos($comment, '*') + 1));
        }
        return '';
    }


    /**
     * 根据模型获取表的注释
     * @param Model $model
     * @return array
     */
    public function getTableDocument(Model $model)
    {
        $createSQL = Db::query("show create table " . $model->getTable())[0]['Create Table'];
        preg_match_all("#`(.*?)`(.*?) COMMENT\s*'(.*?)',#", $createSQL, $matches);
        $fields = $matches[1];
        $comments = $matches[3];
        $fieldComment = [];
        //组织注释
        for ($i = 0; $i < count($matches[0]); $i++) {
            $key = $fields[$i];
            $value = $comments[$i];
            $fieldComment[$key] = $value;
        }
        return $fieldComment;
    }


    /**
     * 获取一个模型关联的模型
     * @param \ReflectionMethod $method
     */
    public function getRelationModel(\ReflectionMethod $method)
    {
        $fileName = $method->getFileName();
        $startLine = $method->getStartLine();
        $endLine = $method->getEndLine();
        $methodContent = $this->readFile($fileName, $startLine, $endLine);
        if (preg_match('/\(([a-zA-Z].*Model)::class/', $methodContent, $m)) {
            $relationModel = $m[1];
            $relationModelClass = $this->getIncludeClassName($method->class, $relationModel);
            if ($relationModelClass) {
                $modelInstance = new $relationModelClass();
                return $modelInstance;
            } else {
                return null;
            }
        } else {
            return null;
        }
    }

    /**
     * 获取响应结果的注释
     * @param array $responseData
     * @param null $model
     * @param array $comments
     * @param string $keyPrefix
     */
    public function getResponseComment(array $responseData, $model = null, &$comments = [], $keyPrefix = '')
    {
        foreach ($responseData as $key => $value) {

            $comments[] = [
                'field' => $this->getPrefix($keyPrefix, $key),
                'type' => $this->getValueType($value),
                'desc' => $this->getKeyDesc($key, $model)
            ];
            if (is_array($value)) {
                if (ArrayHelper::isIndexed($value)) {
                    $nextValue = $value[0];
                } else {
                    $nextValue = $value;
                }
                $relationModel = $model;
                if ($model) {
                    $modelClass = new \ReflectionClass(get_class($model));
                    $methodName = Loader::parseName($key, 1, false);
                    if ($this->checkMethodExist($model, $methodName)) {
                        $method = $modelClass->getMethod($methodName);
                        $relationModel = $this->getRelationModel($method);
                    }
                } else {
                    $relationModel = $model;
                }
                if (!is_array($nextValue)) {
                    continue;// 索引数组为空的时候
                }
                $this->getResponseComment($nextValue, $relationModel, $comments, $this->getPrefix($keyPrefix, $key));
            }
        }
    }

    /**
     * 拼接返回结果前缀
     * @param string $prefix
     * @param string $next
     * @return string
     */
    protected function getPrefix($prefix = "", $next = "")
    {
        if (!$prefix) {
            return $next;
        } else {
            return $prefix . "." . $next;
        }
    }

    /**
     * 检查方法是否存在,父类里面的不算
     * @param $classObject
     * @param $methodName
     * @param string $type
     * @return bool
     */
    protected function checkMethodExist($classObject, $methodName, $type = 'public')
    {
        $methodName = Loader::parseName($methodName,1,false);
        if (!method_exists($classObject, $methodName)) {
            return false;
        }
        $content = $this->getClassFileContent(get_class($classObject));
        if (preg_match("#$type\s*function $methodName#", $content)) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * 获取类文件的内容
     * @param $className
     * @return mixed
     * @throws \Exception
     */
    protected function getClassFileContent($className)
    {
        if (class_exists($className)) {
            $classReflect = new \ReflectionClass($className);
        } else {
            throw new \Exception("类不存在", '1');
        }
        if (!isset($this->classFileMaps[$className])) {
            $this->classFileMaps[$className] = file_get_contents($classReflect->getFileName());
        }
        return $this->classFileMaps[$className];
    }

    public function getIncludeClassName($mainClass, $class)
    {
        $classFile = $this->getClassFileContent($mainClass);
        $pattern = "/use\s*(app.*?\\\\$class)/";
        if (preg_match($pattern, $classFile, $matches)) {
            return $matches[1];
        } else {
            $classReflect = new \ReflectionClass($mainClass);
            $possibleClass = $classReflect->getNamespaceName() . "\\" . $class;
            if (class_exists($possibleClass)) {
                return $possibleClass;
            } else {
                return "";
            }
        }
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容