在之前的文章中,我探索了使用ZendFramework构建服务端点和RESTful服务。使用RPC样式的服务,您可以作弊:协议决定内容类型(XML-RPC使用XML,JSON-RPC使用JSON,SOAP使用XML等)。但是,对于REST,您必须做出选择:您将支持哪种序列化格式?
为什么不支持多种格式?
您没有理由不能重新使用您的RESTful网络服务来支持多种格式。ZendFramework和PHP有很多工具可以帮助您响应不同格式的请求,所以不要限制自己。通过少量工作,您可以让您的控制器格式不可知,并确保您对不同的请求做出适当的响应。
内容类型检测
要解决的第一个问题是如何检索传递的参数。当使用XML或JSON作为序列化格式时,您不会获得标准的POST变量——您得到的是原始帖子,并且您将需要反序列化有效载荷。事实上,如果您收到PUT请求,您还有一些工作要做,因为PHP不会对PUT请求执行任何操作。
我通过一个动作助手来做到这一点。基本算法是:
- 请求中是否有原始正文?如果不是,则无需执行任何操作。
- 确定请求标头中传递的Content-Type,并适当解码:
- 如果是JSON,将原始请求正文传递给
json_decode或Zend_Json::decode。 - 如果是XML,我将原始请求主体传递给
Zend_Config_XML构造函数,并且然后使用toArray()方法序列化为arrya。是的,这是一个hack,但它很有效。 - 否则,我假设我有一个常规PUT样式的请求,并且我将数据传递给
parse_str()。
- 如果是JSON,将原始请求正文传递给
我将这些值保存在动作助手中,然后在我的动作控制器中按需检索它们。帮助程序如下所示:
class Scrummer_Controller_Helper_Params
extends Zend_Controller_Action_Helper_Abstract
{
/**
* @var array Parameters detected in raw content body
*/
protected $_bodyParams = array();
/**
* Do detection of content type, and retrieve parameters from raw body if
* present
*
* @return void
*/
public function init()
{
$request = $this->getRequest();
$contentType = $request->getHeader('Content-Type');
$rawBody = $request->getRawBody();
if (!$rawBody) {
return;
}
switch (true) {
case (strstr($contentType, 'application/json')):
$this->setBodyParams(Zend_Json::decode($rawBody));
break;
case (strstr($contentType, 'application/xml')):
$config = new Zend_Config_Xml($rawBody);
$this->setBodyParams($config->toArray());
break;
default:
if ($request->isPut()) {
parse_str($rawBody, $params);
$this->setBodyParams($params);
}
break;
}
}
/**
* Set body params
*
* @param array $params
* @return Scrummer_Controller_Action
*/
public function setBodyParams(array $params)
{
$this->_bodyParams = $params;
return $this;
}
/**
* Retrieve body parameters
*
* @return array
*/
public function getBodyParams()
{
return $this->_bodyParams;
}
/**
* Get body parameter
*
* @param string $name
* @return mixed
*/
public function getBodyParam($name)
{
if ($this->hasBodyParam($name)) {
return $this->_bodyParams[$name];
}
return null;
}
/**
* Is the given body parameter set?
*
* @param string $name
* @return bool
*/
public function hasBodyParam($name)
{
if (isset($this->_bodyParams[$name])) {
return true;
}
return false;
}
/**
* Do we have any body parameters?
*
* @return bool
*/
public function hasBodyParams()
{
if (!empty($this->_bodyParams)) {
return true;
}
return false;
}
/**
* Get submit parameters
*
* @return array
*/
public function getSubmitParams()
{
if ($this->hasBodyParams()) {
return $this->getBodyParams();
}
return $this->getRequest()->getPost();
}
public function direct()
{
return $this->getSubmitParams();
}
}
这个帮助程序旨在针对每个请求运行,所以我在mybootstrap中注册了它:
class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
{
// ...
protected function _initActionHelpers()
{
// ...
$params = new Scrummer_Controller_Helper_Params();
Zend_Controller_Action_HelperBroker::addHelper($params);
// ...
}
// ...
}
在你的动作控制器中,你需要做的就是调用助手:
$data = $this->params();
在RESTful控制器中,您只需将它与您的postAction和putAction一起使用。美妙之处在于,您的控制器可以忽略Content-Type——无论如何,您都可以编写相同的逻辑来检索您的参数。
响应客户端:上下文切换
因此,问题的前半部分已经解决:如何处理请求。后半部分是适当地响应。
ZendFramework有一些内置工具来帮助解决这个问题。ContextSwitch和AjaxContext操作帮助器寻找一个特定的参数——默认为“格式”——如果检测到,将呈现一个以上下文命名的替代视图脚本。例如,如果检测到“XML”上下文,它将呈现/.xml.phtml——注意脚本的.xml部分姓名。
两个助手都以相同的基本方式工作(后者,AjaxContext,只有在确定请求源自XMLHttpRequest时才会激活):您定义控制器中的哪些操作是上下文敏感的,然后如果检测到上下文,一个新的将使用视图脚本。
因此,第一个技巧是确保传递上下文。如前所述,帮助程序在请求对象中查找“格式”参数。您可以使用查询参数传递它—?format=xml—但我觉得那样很难看。已经为此目的定义了一个HTTP标头:“接受”。
检测标头并将上下文注入请求非常简单,可以在dispatchLoopStartup插件中完成:
class Scrummer_Controller_Plugin_AcceptHandler
extends Zend_Controller_Plugin_Abstract
{
public function dispatchLoopStartup(Zend_Controller_Request_Abstract $request)
{
if (!$request instanceof Zend_Controller_Request_Http) {
return;
}
$header = $request->getHeader('Accept');
switch (true) {
case (strstr($header, 'application/json')):
$request->setParam('format', 'json');
break;
case (strstr($header, 'application/xml')
&& (!strstr($header, 'html'))):
$request->setParam('format', 'xml');
break;
default:
break;
}
}
}
以上可以在你的应用配置中注册:
resources.frontController.plugins[] = "Scrummer_Controller_Plugin_AcceptHandler"
我喜欢我的RESTful控制器自动公开它们的方法作为上下文感知。为了做到这一点,我定义了一个标记接口,Scrummer_Rest_Controller,并创建了一个动作助手来检查当前控制器是否实现它;如果是,我会自动为RESTful操作添加上下文。
class Scrummer_Controller_Helper_RestContexts
extends Zend_Controller_Action_Helper_Abstract
{
protected $_contexts = array(
'xml',
'json',
);
public function preDispatch()
{
$controller = $this->getActionController();
if (!$controller instanceof Scrummer_Rest_Controller) {
return;
}
$this->_initContexts();
// Set a Vary response header based on the Accept header
$this->getResponse()->setHeader('Vary', 'Accept');
}
protected function _initContexts()
{
$cs = $this->getActionController()->contextSwitch;
$cs->setAutoJsonSerialization(false);
foreach ($this->_contexts as $context) {
foreach (array('index', 'post', 'get', 'put', 'delete') as $action) {
$cs->addActionContext($action, $context);
}
}
$cs->initContext();
}
}
也通过引导程序注册:
class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
{
// ...
protected function _initActionHelpers()
{
// ...
$params = new Scrummer_Controller_Helper_Params();
Zend_Controller_Action_HelperBroker::addHelper($params);
$contexts = new Scrummer_Controller_Helper_RestContexts();
Zend_Controller_Action_HelperBroker::addHelper($contexts);
// ...
}
// ...
}
关于这个助手有两点需要注意。首先,您会看到我指定了一个“Vary”标题。这是为了确保如果客户端选择缓存响应,它将根据“接受”标头中发送的值缓存单独的响应。
其次,请注意我在ContextSwitchhelper中关闭了自动JSON序列化。我这样做是为了让我的控制器上下文不可知;这将需要额外的视图脚本,但保持我的控制器逻辑简单的能力将是值得的。稍后会详细介绍。
我们现在拥有基于“Accept”标头响应不同上下文的基础架构,并且可以根据提供给我们的“Content-Type”适当地检索参数。现在是实际响应。
回应客户:观点
回想一下,ContextSwitch会附加一个额外的前缀到指定的viewscript—/.phtml将变成/.xml.phtml或/.json.phtml。基本上,对于我们将响应的每个上下文,每个操作都有一个额外的viewscript。
views/ |-- scripts/ | `-- foo/ | |-- delete.phtml | |-- delete.json.phtml | |-- delete.xml.phtml | |-- get.phtml | |-- get.json.phtml | |-- get.xml.phtml | |-- index.phtml | |-- index.json.phtml | |-- index.xml.phtml | |-- post.phtml | |-- post.json.phtml | |-- post.xml.phtml | |-- put.phtml | |-- put.json.phtml | `-- put.xml.phtml
这看起来有点矫枉过正,但请考虑我的控制器中的以下代表性方法:
public function postAction()
{
$data = $this->params();
$service = $this->getService();
$result = $service->add($data);
if (!$result) {
$this->view->form = $service->getBacklogForm();
return;
}
$this->view->success = true;
$this->view->backlog = $result;
}
您在其中看不到有关标头、重定向或XHR请求的任何内容。只是将数据传送到服务和视图。真的很简单。
然后视图脚本负责适当的显示逻辑。让我们看一下上述操作的两个视图脚本,一个用于普通的旧HTML,另一个用于JSON响应:
<?php // backlog/post.phtml ?>
<?php
if ($this->success):
$this->response->setRedirect($this->url(array(
'controller' => 'backlog',
'id' => $this->backlog->id,
), 'rest', true));
else: ?>
<h2>Create new backlog</h2>
<?php
$this->form->setAction($this->url())
->setMethod('post');
echo $this->form;
endif ?>
<?php // backlog/post.json.phtml ?>
<?php
if ($this->success) {
$url = $this->url(array(
'controller' => 'backlog',
'id' => $this->backlog->id,
), 'rest', true);
$this->response->setHeader('Location', $url)
->setHttpResponseCode(201);
echo $this->json($this->backlog->toArray());
return;
}
$form = $this->form;
$form->setAction($this->url())
->setMethod('post');
echo $this->jsonFormErrors($form);
有几点需要注意:我将我的响应对象注入到视图中。我觉得HTTPheaders是视图的一部分,因此我在那里处理它们。这也有助于使我的控制器保持精简和不可知。此外,您会注意到我对HTML和JSON使用了不同的响应代码——这允许myJSON-REST支持是RESTful,通过返回201状态代码指示资源已创建;我还返回对象的JSON表示。最后,您会注意到我有一个特殊的视图帮助器,用于创建验证错误的JSON表示。
结束点
这篇文章远非详尽无遗,我预计它可能会提出至少与它试图回答的问题一样多的问题。
我在本文中的主要观点是让读者和开发人员创造性地思考如何公开RESTfulWeb服务。希望您能掌握以下内容:
- 以最小化控制器中的代码的方式进行架构;在输入来自何处以及需要何种类型的响应方面尽可能使该代码不可知。
- 使用前端控制器插件和操作助手为您的服务创建脚手架;这些非常灵活且可重复使用,有助于使第1点变得更加容易。
- 尽可能多地卸载您的视图。这将允许您隔离特定于给定格式的逻辑。
还在等什么?您没有要公开的API吗?
