在easyswoole里内置文档
YAPI时代
在之前的文章里我给内网搭了个YAPI文档管理站。在那之前,我跟前端的交流基本全靠企业微信。写好了接口在企业微信把参数和路径发过去,前端有问题了再找我。后来有了YAPI,从那之后我跟前端的互动就友好了很多。写完接口我就把接口填到YAPI里,需要特殊标记的事情我就写到备注里。
就这样,我跟我的前端度过了一年的快乐时光。这段时间里我送走了(他们自己离职)三个前端,在又收获了三个前端之后,我决定重新构建接口文档这一体系。
新的想法
公司的后端项目使用EasySwoole搭配FastRoute开发,正赶上最近开了新的项目,我和另一个后端哥哥想要使用PHP Annotation的注解功能来标注参数,从而进行参数验证。查看EasySwoole文档后发现框架已经自带注解功能了,于是想要使用框架自带的注解功能来获取参数,对接到原来的参数验证方法上。
引入相关的EasySwoole\HttpAnnotation\Utility\AnnotationDoc
库等进行调试,发现EasySwoole的AnnotationDoc
有个很致命的问题,那就是注解获取参数只能在控制器层进行。通常在后端架构上,参数验证功能通常都是在中间件中进行,稍微low一点的(比如我们)也是在BaseController
中进行,但他们这东西只能在业务控制器上进行。如果采用这个方案的话,参数验证就不能统一处理了,只能在进入业务层之前验证前端参数。更鬼畜的是,用这种方式接收的前端参数,要么分成多个控制器参数接受,要么再多写一行注解统一堆到一个变量里。类似这个样子:
/**
* @Param a
* @Param b
* @Param c
*/
function example($a, $b, $c)
{
}
或者是
/**
* @Param a
* @Param b
* @Param c
* @Data $data
*/
function example()
{
}
无论哪种都不能实现我们的需求,于是我打算自己写一个。
在想用注解之前,我们的规则验证是用抽象类规定,在每个业务控制器里都必须包含一个getRule
方法,用来规定每个接口的参数,例如:
protected function getRules()
{
$rules = parent::getRules();
return array_merge($rules, [
'infoMemo' => [
'cid' => ['type' => 'int', 'default' => 0, 'desc' => ''],
'uid' => ['type' => 'int', 'default' => 0, 'desc' => '']
],
'link' => [
'cid' => ['type' => 'int', 'require' => false, 'default' => 0, 'desc' => '显示的cid'],
'mobile' => ['type' => 'string', 'require' => false, 'default' => '', 'desc' => '输入的手机号']
]
]);
}
这样也不是很人性化,特别是一个控制器里的接口多了之后,getRule
方法就会变得特别特别长,代码逐渐变得不可维护。
在我自己的项目中,我都是在业务控制器中调用一个param
方法来单独处理参数,这样就把标注参数的任务平摊到了每个业务控制器上,这样可以让代码看起来更加平均,参数维护起来也很省心。
既然这次接触了注解,那我们就重新设计参数验证方案,来从注解中获取参数,并对接到之前的参数验证逻辑中。
注解格式设计
既然要使用新式的注解,又要兼容之前的验证逻辑,那我们新设计的注解就长这个样子:
/**
* @Apiname 列表
*
* @Param type {"type": "int", "desc": "类型"}
* @Param order_by {"type": "string", "desc": "排序方式"}
* @Param page {"type": "int", "default": "1", "desc": "第几页"}
* @Param limit {"type": "int", "default": "20", "desc": "每页几个"}
*
* @Apidesc 一个列表接口
*
*/
使用@Param
关键字标注一个参数,接下来是参数名,最后是规则。新的规则表达式跟之前的表达式一样,只不过是转换为了json格式,这样更方便后边解析。
获取注解内容并解析
在EasySwoole
中,我们可以使用控制器中的Request
对象来获取当前请求的程序路径,例如这样就可以获取到当前程序运行的命名空间、类、方法:
$this->request()->getUri()->getPath()
于是我们经过一番操作,就能分别获取到当前的类的路径和方法名。
$path = $this->request()->getUri()->getPath();
$pathArr = explode('/', $path);
$pathArrWithoutFunction = $pathArr;
$functionName = $pathArrWithoutFunction[count($pathArrWithoutFunction) - 1];
unset($pathArrWithoutFunction[count($pathArrWithoutFunction) - 1]);
$controllerName = 'App\\HttpController\\' . implode('\\', $pathArrWithoutFunction);
// $controllerName 是控制器类路径
// $functionName 是方法名
然后通过PHP的反射类,获取到对应的注释文本。
$ref = new \ReflectionClass($controllerName);
$methodRef = $ref->getMethod($functionName);
$doc = $methodRef->getDocComment();
// $doc 是完整的注释字符串
然后我们通过explode
函数,使用\n
将注释按行分割,逐行解析注释内容,提取到我们在注释中写的参数名和规则。
$_a = strpos($line, '@Param') + 6;
$_b = strpos($line, "{");
$_name = trim(substr($line, $_a, $_b - $_a));
$_value = substr($line, $_b);
$_rule = array_merge(json_decode($_value, true) ?? [], self::$commonRule);
// $_name 是参数名
// $_rule 是规则数组
// self::$commonRule 是控制器里定义的公共参数,类似于appid之类的
这样我们就可以将参数名和规则数组传入原来的参数验证逻辑中处理了。到此为止,新的注解方式的参数标注就已经完全对接到我们原来的参数验证逻辑上了。
本来这个想法应该到此为止,但是我不满足于仅仅一个参数验证,我还希望借助这个注解的方式,可以自动生成接口文档。于是就有了后面的内容。
抛弃YAPI
由于所有的路由都定义在一张路由表Route.php
中,所以我们可以从中获取到当前项目全部的接口。这里就出现了另一个恶心的事情,那就是FastRoute
没有获取全部路由列表的功能(或者是我找遍了文档都没找到)。只能再次使用解析字符串的老本行了。
使用file_get_contents
读取文件内容,再解析字符串。遇到带addGroup
的行就分组,遇到post
或get
就用反射处理注释。
$routeFileContent = file_get_contents(self::$routerFilePath);
$routeFileContentArr = [];
$currentGroup = "/";
foreach (explode("\n", $routeFileContent) as $line) {
if (stripos($line, 'addGroup') !== false) {
preg_match_all('/addGroup\((.*), function/', $line, $matchGroup);
$currentGroup = str_replace(["\""], "", $matchGroup[1][0]);
} if (stripos($line, '$route->get') !== false || stripos($line, '$route->post') !== false) {
preg_match_all('/\$route->(post|get)((.*), (.*));/', $line, $matchRoute);
$method = $matchRoute[1][0] ?? '';
$url = str_replace(["(", "'", '"'], '', $matchRoute[3][0] ?? '');
$function = str_replace(['"', ")", "'"], '', $matchRoute[4][0] ?? '');
$group = $currentGroup;
$params = [];
// ...
就这样,我们就得到了一个完整的路由数组。
为了显示页面,我们需要写一个简单的模板变量替换的方法。
/**
* 模板变量替换
* @param $templateFilePath
* @param $data
* @return false|string|string[]
*/
public static function C($templateFilePath, $data)
{
if (file_exists($templateFilePath)) {
$content = file_get_contents($templateFilePath);
foreach ($data as $k => $v) {
$content = str_replace('{{'. $k .'}}', $v, $content);
}
return $content;
} else {
return "";
}
}
然后使用这个简陋的模板渲染方法,将页面一点一点渲染出来。
public static function getDocHtml(Response $response)
{
if (ENVIRONMENT !== 'development') {
self::display($response, "");
} else {
$allRoutes = self::getAllRoutes();
$html = self::C(self::$phpdocPath . "/index.html", [
"title" => "接口文档",
"baseHost" => Config::getInstance()->getConf('BASE_HOST'),
"routes" => implode('', array_map(function($group) {
return self::C(self::$phpdocPath . '/routeGroup.html', [
'groupName' => $group['group'],
'routes' => implode('', array_map(function($route) {
return self::C(self::$phpdocPath . '/route.html', [
'method' => $route['method'],
'route' => $route['url'],
'group' => $route['group'],
'function' => $route['function'],
'apiname' => $route['apiname'] ?: "{$route['group']}{$route['url']}",
'apidesc' => $route['apidesc'],
'params' => implode('', array_map(function($param) {
return self::C(self::$phpdocPath . '/param.html', [
"name" => $param['name'],
"rule_desc" => $param['rule']['desc'] ?? '',
"rule_require" => $param['rule']['require'] == 1 ? "是" : "否",
"rule_type" => $param['rule']['type'] ?? '',
"rule_enum" => implode(',', $param['rule']['enum'] ?? []),
]);
}, $route['params'])),
]);
}, $group['routes'] ?? []))
]);
}, $allRoutes ?? [])),
"columns" => implode("", array_map(function($route) {
return self::C(self::$phpdocPath . '/columnItem.html', [
'name' => implode(" ", [$route['group'], $route['apiname'] ?: $route['url']]),
'id' => "{$route['group']}{$route['url']}"
]);
}, (function () use($allRoutes){
$_allRoutes = [];
foreach ($allRoutes as $v) {
foreach ($v['routes'] as $vv) {
array_push($_allRoutes, $vv);
}
}
return $_allRoutes;
})()))
]);
self::display($response, $html);
}
}
如此大费周折,还不是模板变量替换方法太简陋了。为了前后端逻辑分离,在这里浪费了太多精力。
最后我们将得到的html字符串传送到浏览器就可以了。
$response->write($html);
$response->withHeader('Content-type', 'text/html; charset=UTF-8');
$response->withStatus(200);
最后的实现结果是
新的计划
写到这里我就有点沾沾自喜了,因为接下来的开发里,我不需要再写完接口去给前端填写YAPI了,省去了一大部分编写文档的时间。
但到了自己调试接口的时候我就发现问题了,没有YAPI的话,我自己没法调试啊!YAPI有发送接口的功能,但我这玩意没有。要是想让我这个东西能发包,我还得开发个chrome插件来实现,或者是把公司前端项目里的参数验证逻辑也搬进来,包括恶心人的参数签名。
然后我发现了YAPI有json导入功能,可以识别YAPI、Postman、Swagger生成的json文件。于是我就有了下一步的计划。
下一步打算将获得的路由数组处理成YAPI能读的格式,然后对外网开放一个获取JSON的接口,这样把接口填到YAPI的后台,就能让YAPI也自动拉取接口文档了。这样就真的全自动了。