# 用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。没有太多的复杂逻辑在控制器里。后端做的事就是设计好数据库、写好控制器和继续学习把。
- 序
- 前言
- 内容简介
- 目录
- 基础知识
- 起步
- 控制器
- 模型
- 模板
- 命名空间
- 进阶知识
- 路由
- 配置
- 缓存
- 权限
- 扩展
- 国际化
- 安全
- 单元测试
- 拿来主义
- 调试方法
- 调试的步骤
- 调试工具
- 显示trace信息
- 开启调试和关闭调试的区别
- netbeans+xdebug
- Socketlog
- PHP常见错误
- 小黄鸭调试法,每个程序员都要知道的
- 应用场景
- 第三方登录
- 图片处理
- 博客
- SAE
- REST实践
- Cli
- ajax分页
- barcode条形码
- excel
- 发邮件
- 汉字转全拼和首字母,支持带声调
- 中文分词
- 浏览器useragent解析
- freelog项目实战
- 需求分析
- 数据库设计
- 编码实践
- 前端实现
- rest接口
- 文章发布
- 文件上传
- 视频播放
- 音乐播放
- 图片幻灯片展示
- 注册和登录
- 个人资料更新
- 第三方登录的使用
- 后台
- 微信的开发
- 首页及个人主页
- 列表
- 归档
- 搜索
- 分页
- 总结经验
- 自我提升
- 进行小项目的锻炼
- 对现有轮子的重构和移植
- 写技术博客
- 制作视频教程
- 学习PHP的知识和新特性
- 和同行直接沟通、交流
- 学好英语,走向国际
- 如何参与
- 浏览官网和极思维还有看云
- 回答ThinkPHP新手的问题
- 尝试发现ThinkPHP的bug,告诉官方人员或者push request
- 开发能提高效率的ThinkPHP工具
- 尝试翻译官方文档
- 帮新手入门
- 创造基于ThinkPHP的产品,进行连带推广
- 展望未来
- OneThink
- ThinkPHP4
- 附录