作为一名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 接口的业务功能和含义描述
这一部也不难,根据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":"保定"
}
}
}
策略:
- 对于最外面一层的信息,通过在控制器里面获取到对应service,可以获取到service关联的模型,根据模型可以获取到数据表的创建语句信息,然后解析语句中包含的注释,可以获取到对应的值的含义。不能匹配到标记为不明确,如果模型没有获取到就不必再匹配了。
- 对于以data结尾的,可以在模型中找是否存在方法名,存在方法就获取方法的注释,以data结尾的如果是一对一就是关联数组,否则就是索引数组,这时可以取数组中的第一个。作为将要递归循环的数组,同样将该方法中所使用的模型用正则匹配出来,比如解析company_data的时候要匹配出,里面用的是CompanyModel,然后根据这两项重复第一步。
细节:
1.判断一个方法存不存在,method_exist在这里不准确,因为这里只是希望看到某个模型文件中定义的关联方法,所以需要手动的做一次方法名在类所在的文件名的匹配,否则可能会把thinkphp自带的Model的方法也算上,造成统计错误。
2.对于append的属性,需要自己想办法匹配getXXXattr方法。
3.很多地方需要驼峰和下划线的转换。
- 如何灵活使用?自己定义了一个特殊的参数,比如如果请求参数中包含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 "";
}
}
}
}