💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
# 用restful的原因 现在移动应用越来越流行,web后端也被要求能开发api了。而api的话restfull 相比web socket和 web service 、soap之类的更简单和简洁。实现起来也最容易。 而ThinkPHP3.1 版本就支持了RestController 支持restfull api 开发。 ## 本人经历 而本人曾尝试过一次用restController开发 一个机票应用。后来在jobdeer又用了另外一个api 框架lazyPHP4 开发jobdeer项目的api。 所以对于api这边应该还算有点了解。 但是,看了 [《RESTful API 设计指南》](http://www.ruanyifeng.com/blog/2014/05/restful_api.html) 后,觉的我们对api理解还不够深入,或者没有做到最好。 ## 他人吐槽 外加官网曾有人发帖[《用Thinkphp做不到Restful的URL风格》](http://www.thinkphp.cn/topic/28423.html) ,我觉的官方一直没有提供一个好的restfull 实现案列。 所以我在本项目实战里特意去研究和使用最新版restController,希望能分享我的理解。 首先,我把他的需求理解一下,第一要支持rest方法请求类型,第二要直接User前面不带任何其他的Api参数。 其实他的问题省略Api那个,要么写空控制器,要么用路由,就能把一些url映射过去访问。 其他的本来就可以实现。 而我之前和归归有过一次关于api的讨论。他吐槽了好多。。 他们是麦客疯app,用TP开发的手机api。 1.要维护多个版本。 ![2015-06-10/5577fc9c974f2](http://box.kancloud.cn/2015-06-10_5577fc9c974f2.png) 其实好多接口版本比例不足1%,某些低版手机app版本应该舍弃的。 2.没有返回状态码,请求成功都是200。 我在《HTPP权威指南里》看到 “post时新增 成功 201” ,“更新时 如果发现 依赖条件缺乏不处理 412”等,这样程序员可以方便定位逻辑错误和数据错误。 3.权限问题 他们数据加密了。 4.规范问题 如coding的 是 [raml](https://coding.net/u/baoti/p/Coding-API/git) ![2015-06-10/557801ced72b6](http://box.kancloud.cn/2015-06-10_557801ced72b6.png) breeze 里用的微软的 odata。 5.数据级联问题 在jobdeer里,罗飞的要求是尽量在一个接口里搞定,让客户端少请求。然后业务和数据杂糅。一个接口写了好长。有时还会调用第三方服务。 而我理解的是应当面向资源。 6.接口测试 我们公司的是用phpunit单元测试做黑盒测试,测试接口访问性,返回参数 响应code 是不是对。后来接口规模上去了,300多个,每次跑测试2分钟。如果要按他们6个版本算就是1800个。杀了我吧。 他们公司是人工测试 手动点app触发。 ## 自己的实现 所以总结了上面的各种情况,我希望我实现的restfull能做到以下几点: - url要短,简洁 类型都不在url里了能不短吗? - 安全考虑 - 面向资源 - 区分返回状态码 - 提示统一 - 复用代码 - 返回格式固定 ### 接口的调试 在开发接口时,我们经常想知道,传给接口的参数变量是什么,以及接口里各个分支是否运行到,还有最后的操作数据库sql是什么,如果我们随时dump,会破坏接口的返回,一般是不允许的。所以我们需要一个工具来调试这些信息,原始点可以写日志,效率高点,就要借助之前我们已经学习过调试异步的好工具“Socketlog”了。 而对于接口表单的构建,简单点,可以js里 jquery ajax 调用。当然实际上页面里也是这么做,当我们页面还没有时,其实就已经可以开发接口了。 这可以借助于“Postman”。Chrome应用。 ![2015-06-10/55780a02a5b07](http://box.kancloud.cn/2015-06-10_55780a02a5b07.png) 他本身就是一个rest client。可以支持rest的请求方法。支持多个参数,响应支持预览xml和json。 最主要的是可以收藏你的一次测试请求包括参数url等各种输入。另外文件上传也可以在参数里选择: ![2015-06-10/55780a8ad2e02](http://box.kancloud.cn/2015-06-10_55780a8ad2e02.png) 总之就是一个神器,还跨平台。 ### 我的实现 #### 代码复用 我认为接口应该单独做为一个模块,不应该放入主应用模块中,这样方便以后好扩展。只要我的表设计好,api模块,随时可以拷贝到别的项目中,然后那个项目再开发一个前台和后台就完事了。 因此,我采用了下面的结构: ![2015-06-11/5578d08116222](http://box.kancloud.cn/2015-06-11_5578d08116222.png) Api+Common 模块,Api负责rest,Common负责公共模型和函数。 #### URL短一点,再短一点 之前那个同学说 无法实现 `GET users` 而只做到了 `http://demo.com/Api/User` 这样的地址,其实很好理解。他用了Api控制器继承restController了。因此URL里必须有Api。 那么如何省略Api呢?最好的方法是用空控制器。用路由太麻烦了,每增加一个路由,就得增加一个配置项。 再配合.htaccess 文件隐藏入口。就已经做到了 `GET users`能进入空控制器了。 #### 整个控制器代码 为了方便大家理解代码,我先将整个代码放在这,后面一个片段一个片段的讲。 ~~~ <?php namespace Api\Controller; use Think\Controller\RestController; class EmptyController extends RestController{ protected $allowMethod = array('get','post','put','delete'); // REST允许的请求类型列表 protected $allowType = array('json'); // REST允许请求的资源类型列表 protected $defaultType = 'json'; protected $allowOutputType = array( 'json' => 'application/json', ); protected $otherResource = array( 'pic', 'file', 'config', ); public function _initialize(){ $this->resource_name = strtolower(CONTROLLER_NAME); $this->messages = array( 'get' => '获取', 'put' => '更新', 'post' => '新增', 'delete' => '删除', ); $config = S('DB_CONFIG_DATA'); if (!$config) { /* 读取站点配置 */ $map = array('status' => 1); $configModel = D('Config'); $data = $configModel->where($map)->field('type,name,value')->select(); $config = array(); if ($data && is_array($data)) { foreach ($data as $value) { $config[$value['name']] = $configModel->parse($value['type'], $value['value']); } } S('DB_CONFIG_DATA', $config); } C($config); //添加配置 } public function _empty($name){ $table = $this->resource_name; if(!in_array($table, $this->otherResource)){ //先判断表存不存在 if(!M()->query("SHOW TABLES LIKE '".C('DB_PREFIX')."{$table}'")){ $this->response(array('code'=>404, 'message'=> "Resource '{$this->resource_name}' doesn't exist"), $this->defaultType, 404); } }else{ if(method_exists($this, $table)) $this->$table($name); } $model = D(ucfirst($table)); $result = true; $data = array(); $code = 404; $url = ''; switch ($this->_method){ case 'head': break; case 'option': break; case 'get': // 列出资源 if('list' == $name){ $data = $model->select(); }else{ $id = intval($name); $data = $model->find($id); } if($model->getError() || $model->getDbError()){ $result = false; }else{ $code = 200; } break; case 'put': // 更新资源 $puts = $model->create(I('put.')); if(false === $puts){ $result = false; $data = $model->getError(); }else{ $id = intval($name); if($find = $model->find($id)){ $result = false !== $model->save($puts); $code = $result? 200: 404; }else{ $result = false; $data = "record not found"; $code = 412; } } break; case 'post': // 新增资源 $posts = $model->create(); if(false == $posts){ $data = $model->getError(); $result = false; }else{ $id = $model->add(); if(!$id){ $result = false; }else{ $code = 201; $data = $id; } } break; case 'delete':// 删除资源 $id = I('get.id',0); slog($id); if($find = $model->find($id)){ $result = $model->delete($id); $code = $result? 200: 404; $url = $_SERVER['HTTP_REFERER']; }else{ $result = false; $data = "record not found"; $code = 412; } break; } if($result){ $this->success($data, $code, $url); }else{ $this->error($data, $code, $url); } } public function config($name = 0){ $model = D('Config'); $result = true; $data = array(); $code = 404; $url = ''; switch ($this->_method){ case 'head': break; case 'option': break; case 'get': // 列出资源 if('list' == $name){ $data = $model->select(); }else{ $id = intval($name); $data = $model->find($id); } if($model->getError() || $model->getDbError()){ $result = false; }else{ $code = 200; } break; case 'put': if('save' == $name){ // 批量更新资源 $config = I('put.config'); if(empty($config)){ $result = false; $data = '表单为空'; }else{ if($config && is_array($config)){ foreach ($config as $name => $value) { $map = array('name' => $name); $model->where($map)->setField('value', $value); } } S('DB_CONFIG_DATA',null); $code = 200; } }else{ $puts = $model->create(I('put.')); if(false === $puts){ $result = false; $data = $model->getError(); }else{ $id = $puts['id']; if($find = $model->find($id)){ $result = false !== $model->save($puts); $code = $result? 200: 404; }else{ $result = false; $data = "record not found"; $code = 412; } } } break; case 'post': // 新增资源 $posts = $model->create(); if(false == $posts){ $data = $model->getError(); $result = false; }else{ $id = $model->add(); if(!$id){ $result = false; }else{ $code = 201; $data = $id; $url = '/admin.php/Config/index'; } } break; case 'delete':// 删除资源 // parse_str(file_get_contents('php://input'), $_DELETE); // slog($_DELETE); $id = array_unique((array)I('get.id',0)); slog($id); if ( empty($id) ) { $code = 404; $data = '请选择要操作的数据'; }else{ $code = 200; $map = array('id' => array('in', $id) ); if(M('Config')->where($map)->delete()){ S('DB_CONFIG_DATA',null); //记录行为 $url = '/admin.php/Config/index'; $data = '删除成功'; } else { $code = 412; $result = false; $data = '删除失败!'; } } break; } if($result){ $this->success($data, $code, $url); }else{ $this->error($data, $code, $url); } } public function success($data, $code=200, $url=''){ $response = array( 'code'=>$code, 'data'=>$data, 'info'=>$this->response_info($this->resource_name, $this->_method, 'succeed') ); if($url) $response['url'] = $url; $this->response($response, $this->defaultType, $code); } public function error($data, $code=404, $url=''){ $response = array( 'code'=>$code, 'info'=>$this->response_info($this->resource_name, $this->_method, 'failed') ); if($data) $response['info'] .= ". 原因: {$data}"; if($url) $response['url'] = $url; $this->response($response, $this->defaultType, $code); } private function response_info($resource, $method, $flag){ static $resource_name_strings = array( 'post' => '博文', 'config' => '配置', 'file' => '文件', 'picture' => '图片', 'member' => '用户', 'message' => '消息', 'sns' => '第三方登录账号', 'tags' => '标签', 'url' => '外链', ); $action_strings = $this->messages; $resource_name = isset($resource_name_strings[$resource])? $resource_name_strings[$resource] : $resource; $action = isset($action_strings[$method])? $action_strings[$method] : $method; $action_flag = 'succeed' == $flag ? '成功' : '失败'; return sprintf('%s%s%s', $action, $resource_name, $action_flag); } } ~~~ #### 分解讲解 1.REST模式的定制化 首先rest是支持多种方法和返回类型的。为了简化问题,我演示的这个应用约定rest接口管 'get','post','put','delete' 四个方法,允许请求和输出的类型都是json。 因此有下面的属性 ~~~ protected $allowMethod = array('get','post','put','delete'); // REST允许的请求类型列表 protected $allowType = array('json'); // REST允许请求的资源类型列表 protected $defaultType = 'json'; protected $allowOutputType = array( 'json' => 'application/json', ); ~~~ 因为从开发角度来讲,api支持的模式越多越好,但是从项目管理的角度来讲,支持xml等格式,代表我前端代码里要写这些请求,这样整个项目里有2种或两种以上的请求方式,给项目造成混乱,且效率不高。 > 问题如果经过的步骤越多,出问题的机率越高。 我的需求我来定。就json了,这是目前最流行的格式。 ~~~ protected $otherResource = array( 'pic', 'file', 'config', ); ~~~ 这边先记一下,其他可获取处理的资源。后面讲empty方法时会说明。 2.初始化操作处理 ~~~ public function _initialize(){ $this->resource_name = strtolower(CONTROLLER_NAME); $this->messages = array( 'get' => '获取', 'put' => '更新', 'post' => '新增', 'delete' => '删除', ); $config = S('DB_CONFIG_DATA'); if (!$config) { /* 读取站点配置 */ $map = array('status' => 1); $configModel = D('Config'); $data = $configModel->where($map)->field('type,name,value')->select(); $config = array(); if ($data && is_array($data)) { foreach ($data as $value) { $config[$value['name']] = $configModel->parse($value['type'], $value['value']); } } S('DB_CONFIG_DATA', $config); } C($config); //添加配置 } ~~~ 初始化方法里,我做了3件事。将控制器方法全部转小写赋值给`resource_name`这个类属性、定义了messages操作提示说明属性、参照OneThink里将后台的配置合并到项目中(并加了缓存)。 3. 核心empty 方法 我的rest主要逻辑就在empty方法中。 整体的结构是 1. 资源网关判断 2. 根据请求类型获取数据 3. 返回资源数据 ##### 资源网关判断 ~~~ $table = $this->resource_name; if(!in_array($table, $this->otherResource)){ //先判断表存不存在 if(!M()->query("SHOW TABLES LIKE '".C('DB_PREFIX')."{$table}'")){ $this->response(array('code'=>404, 'message'=> "Resource '{$this->resource_name}' doesn't exist"), $this->defaultType, 404); } }else{ if(method_exists($this, $table)) $this->$table($name); } ~~~ 首先获取资源名(已经全部小写了),然后去其他资源里检索,不存在的话判断该资源是数据库表结构类型资源,然后查表,看该表存在不存在。不存在直接报错(常见的非法请求资源)。 存在的话访问后面的。而其他资源网关白名单里,资源存在的话,如果存在资源方法,执行自定义的资源方法。 就是 `$this->$table($name);`,这句。为什么要这一句?为了复用代码。 官方的里面,针对某一资源的不同请求类型,是需要定义不同方法的。 假设像下面的: ~~~ Public function read_get_json(){ // 输出id为1的Info的html页面 } Public function read_post_json(){ // 新增数据 } Public function read_put_json(){ // 更新数据 } Public function read_delete_json(){ // 删除数据 } ~~~ 我那样写是支持一个地址,一个方法覆写4种类型,这样其实 更新和获取资源里的查询方法可以复用。总之方法灵活了不少。 ##### 请求资源 ~~~ $model = D(ucfirst($table)); $result = true; $data = array(); $code = 404; $url = ''; switch ($this->_method){ case 'head': break; case 'option': break; case 'get': // 列出资源 if('list' == $name){ $data = $model->select(); }else{ $id = intval($name); $data = $model->find($id); } if($model->getError() || $model->getDbError()){ $result = false; }else{ $code = 200; } break; case 'put': // 更新资源 $puts = $model->create(I('put.')); if(false === $puts){ $result = false; $data = $model->getError(); }else{ $id = intval($name); if($find = $model->find($id)){ $result = false !== $model->save($puts); $code = $result? 200: 404; }else{ $result = false; $data = "record not found"; $code = 412; } } break; case 'post': // 新增资源 $posts = $model->create(); if(false == $posts){ $data = $model->getError(); $result = false; }else{ $id = $model->add(); if(!$id){ $result = false; }else{ $code = 201; $data = $id; } } break; case 'delete':// 删除资源 $id = I('get.id',0); slog($id); if($find = $model->find($id)){ $result = $model->delete($id); $code = $result? 200: 404; $url = $_SERVER['HTTP_REFERER']; }else{ $result = false; $data = "record not found"; $code = 412; } break; } ~~~ 如果资源非自定义资源,就走通用资源处理逻辑,就是上面的代码。 一般都能看懂。 先获取模型,然后请求方法类型,get的话是获取数据。大家注意empty里的$name 这个实际上获取的是 资源URL 第一个**/** 分割后的字符串。 比方说 `get user` 原本$name 会为空 但是我Api模块配置里`DEFAULT_ACTION`默认了list ,所以会是list;而 `get user/1` $name 为1。 这代码中的list 是我在配置里定义的。 ~~~ <?php return array( 'URL_HTML_SUFFIX' => '', 'DEFAULT_ACTION' => 'list', ); ~~~ 这样符合实际意义。没有参数就是获取全部列表。 然后就是更新和删除时限查找原始数据, 没有原始数据,响应码是不一样。 ##### 发送响应 ~~~ if($result){ $this->success($data, $code, $url); }else{ $this->error($data, $code, $url); } ~~~ empty 方法中最后返回了响应,为了和以前非api编码方式保持一致,我覆写了控制器里 success和error方法 ~~~ public function success($data, $code=200, $url=''){ $response = array( 'code'=>$code, 'data'=>$data, 'info'=>$this->response_info($this->resource_name, $this->_method, 'succeed') ); if($url) $response['url'] = $url; $this->response($response, $this->defaultType, $code); } public function error($data, $code=404, $url=''){ $response = array( 'code'=>$code, 'info'=>$this->response_info($this->resource_name, $this->_method, 'failed') ); if($data) $response['info'] .= ". 原因: {$data}"; if($url) $response['url'] = $url; $this->response($response, $this->defaultType, $code); } ~~~ success里第一个参数是返回数据,后面是响应码,最后是可选的url参数,那篇文章里有 Hypermedia API的概念,预留起来作为跳转url也行。 error里 data是用来作补充原因说明的,因为错误响应应该不需要返回太多数据。 响应两个方法里用到了 一个私有方法 response_info。 ~~~ private function response_info($resource, $method, $flag){ static $resource_name_strings = array( 'post' => '博文', 'config' => '配置', 'file' => '文件', 'picture' => '图片', 'member' => '用户', 'message' => '消息', 'sns' => '第三方登录账号', 'tags' => '标签', 'url' => '外链', ); $action_strings = $this->messages; $resource_name = isset($resource_name_strings[$resource])? $resource_name_strings[$resource] : $resource; $action = isset($action_strings[$method])? $action_strings[$method] : $method; $action_flag = 'succeed' == $flag ? '成功' : '失败'; return sprintf('%s%s%s', $action, $resource_name, $action_flag); } ~~~ 这个方法是用来友好提示的。本来想,就用 资源+动作+结果-> post get succes。这样的,后来一想 前台给人用,还要参加coding Html5比赛,干脆格式化一下。 整个restful 实现就是这样,最后再加上api.php入口的参数绑定, ~~~ <?php define('APP_PATH','./App/'); define('APP_DEBUG', 1); define('BIND_MODULE','Api'); if(!function_exists('slog')){ require './SocketLog.class.php'; $slog_config=array( 'host'=>'i.kuaijianli.com', 'port'=>1229, 'error_handler'=>true, 'optimize'=>true, 'allow_client_ids'=>array('yangweijie_jay'), 'show_included_files'=>false ); if(isset($_GET['slog_force_client_id'])){ $slog_config['force_client_id'] = $_GET['slog_force_client_id']; } slog($slog_config,'set_config'); } require './ThinkPHP/ThinkPHP.php'; ~~~ 至于config方法,可以先不看,只是我针对后台配置定义的接口,实现OneThink里 配置管理。 ##### 权限的思考 有的时候我们接口会有一些需求,某数据所有者才能操作。有的数据某些条件才能操作。所以我选择了多入口,api入口去做。这样的好处是什么?共享session。因为只是入口不同,域名一样,session完全可以共享。 这样,我在`/App/Api/Common/function.php`里只定义了一个is_login函数。 ~~~ /** * 判断是否登录,如果登录了返回uid */ function is_login(){ return session('?user')? session('user.uid'): 0; } ~~~ 如果有权限需要,接口里可以加一层登录网关判断,添加不必要登录请求接口网关就可以了。 至于关联数据,我们TP有模型。可以after 后置 select|find|update|delete。 这些不是要担心的。 #### 关于代码风格 所有代码不超过350行。 我不是不喜欢注释,但是觉得有时候自注释的代码才是好的,明明有英文方法名和参数名,能把你的目的表达出来。额外的添加注释,是为了照顾不懂英文的新人吗? 我记得一个关于编码不会出错一段话,大意是,有两种方式保证你写代码不会出错:1.将代码写的简答的谁都能懂,毫无疑问的不出错;2.另外一种是把代码写的复杂到没人看懂的不出错。 我认为代码的实现就应该简单、简洁,一眼能懂。编程语言也是门语言。你写代码是为了计算机运行。虽然有时候大师写的代码很简洁。但是对于计算机来说结果正确,人人能看懂你意图的代码就是好代码。就好比你给一个女子写信,不论你文辞多么好,有诗意,你用文言文说“窈窕淑女,君子好逑”和“美女啊,我喜欢你”效果是不一样的。表达意思一样,但就理解的人来说,明显后面的人要多一些。 ## 后话 我喜欢rest,因为他把后端的事情简化了。只有curd。没有太多的复杂逻辑在控制器里。后端做的事就是设计好数据库、写好控制器和继续学习把。