# 用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.要維護多個版本。

其實好多接口版本比例不足1%,某些低版手機app版本應該舍棄的。
2.沒有返回狀態碼,請求成功都是200。
我在《HTPP權威指南里》看到 “post時新增 成功 201” ,“更新時 如果發現 依賴條件缺乏不處理 412”等,這樣程序員可以方便定位邏輯錯誤和數據錯誤。
3.權限問題
他們數據加密了。
4.規范問題
如coding的 是 [raml](https://coding.net/u/baoti/p/Coding-API/git)

breeze 里用的微軟的 odata。
5.數據級聯問題
在jobdeer里,羅飛的要求是盡量在一個接口里搞定,讓客戶端少請求。然后業務和數據雜糅。一個接口寫了好長。有時還會調用第三方服務。
而我理解的是應當面向資源。
6.接口測試
我們公司的是用phpunit單元測試做黑盒測試,測試接口訪問性,返回參數 響應code 是不是對。后來接口規模上去了,300多個,每次跑測試2分鐘。如果要按他們6個版本算就是1800個。殺了我吧。
他們公司是人工測試 手動點app觸發。
## 自己的實現
所以總結了上面的各種情況,我希望我實現的restfull能做到以下幾點:
- url要短,簡潔 類型都不在url里了能不短嗎?
- 安全考慮
- 面向資源
- 區分返回狀態碼
- 提示統一
- 復用代碼
- 返回格式固定
### 接口的調試
在開發接口時,我們經常想知道,傳給接口的參數變量是什么,以及接口里各個分支是否運行到,還有最后的操作數據庫sql是什么,如果我們隨時dump,會破壞接口的返回,一般是不允許的。所以我們需要一個工具來調試這些信息,原始點可以寫日志,效率高點,就要借助之前我們已經學習過調試異步的好工具“Socketlog”了。
而對于接口表單的構建,簡單點,可以js里 jquery ajax 調用。當然實際上頁面里也是這么做,當我們頁面還沒有時,其實就已經可以開發接口了。
這可以借助于“Postman”。Chrome應用。

他本身就是一個rest client。可以支持rest的請求方法。支持多個參數,響應支持預覽xml和json。 最主要的是可以收藏你的一次測試請求包括參數url等各種輸入。另外文件上傳也可以在參數里選擇:

總之就是一個神器,還跨平臺。
### 我的實現
#### 代碼復用
我認為接口應該單獨做為一個模塊,不應該放入主應用模塊中,這樣方便以后好擴展。只要我的表設計好,api模塊,隨時可以拷貝到別的項目中,然后那個項目再開發一個前臺和后臺就完事了。
因此,我采用了下面的結構:

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
- 附錄