与Exchange交谈SOAP
以前,在不使用Microsoft产品的情况下与Exchange进行交流几乎是不成问题的。 二进制MAPI协议是专有的,记录不良。 Exchange支持IMAP和POP,但这些协议只能提供电子邮件,而不是日历,地址簿,待办事项列表等。但从2007版开始,Exchange现在附带一个名为Exchange Web Services或EWS的SOAP接口。 该界面使我们能够访问在任何平台上以任何编程语言编写客户端所需的功能。
本文介绍了在Exchange日历中查找,删除和插入项目的PHP程序。
概述
SOAP是一种基于XML的Web服务标准。 PHP在单独的模块中支持SOAP。 SOAP规范的一部分是WSDL,一种基于XML的Web服务定义语言,它定义了可用的数据类型和功能。 EWS中的功能和数据类型实际上在MSDN上有很好的记录: http : //msdn.microsoft.com/en-us/library/bb204119.aspx 。 EWS使用HTTPS协议进行通信,而不是基本身份验证,它使用Microsoft特定的NTLM身份验证。 PHP不支持SOAP协议,但正如我们将看到的,我们可以解决这个问题。
剧本
PHP中的一个普通的SOAP通信是这样的:
$wsdl = "http://example.com/webservice/definition.wsdl"; $client = new SoapClient($wsdl); $request = 123; $response = $client->MyFunction($request); # Do something with the response
在Exchange 2007服务器上,WSDL文件通常位于https://exchange.example.com/EWS/Services.wsdl。 要访问此文件,我们需要Exhange服务器上有效用户的用户名和密码。 但是,由于Exchange使用NTLM身份验证,因此我们需要为SoapClient创建一个包装器。 CURL库(也被称为PHP库)支持NTLM身份验证,因此我们将使用它来构建包装器:
class NTLMSoapClient extends SoapClient { function __doRequest($request, $location, $action, $version) { $headers = array( 'Method: POST', 'Connection: Keep-Alive', 'User-Agent: PHP-SOAP-CURL', 'Content-Type: text/xml; charset=utf-8', 'SOAPAction: "'.$action.'"', ); $this->__last_request_headers = $headers; $ch = curl_init($location); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_POST, true ); curl_setopt($ch, CURLOPT_POSTFIELDS, $request); curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_NTLM); curl_setopt($ch, CURLOPT_USERPWD, $this->user.':'.$this->password); $response = curl_exec($ch); return $response; } function __getLastRequestHeaders() { return implode("n", $this->__last_request_headers)."n"; } }
该类覆盖了SoapClient的doRequest函数,以使用CURL来获取WSDL文件。 根据您的PHP安装,您可能需要安装PHP CURL模块才能使其工作。 编辑:如果您遇到SoapClient错误,您可能需要禁用SSL证书验证。 我没有发现这些错误的真正原因(这不仅仅是一个过期的证书),显然这是一个安全风险来禁用验证,但它可能是你需要解决的错误。 将这些选项添加到上面的__doRequest()方法中:
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
编辑2:如果您看到“看起来我们没有XML文档”SoapFault,可能是因为服务器使用非XML文档进行响应。 在我的情况下,响应是HTML 401身份验证错误页面。 打印上面的doRequest函数中的$request
和$response
对象在调试时是一个很大的帮助。 我通过删除包含“CURLAUTH_NTLM”的行解决了auth错误,显然NTLM身份验证并不总是被使用。 好吧。
我们在另一个包装器中提供用户名和密码:
class ExchangeNTLMSoapClient extends NTLMSoapClient { protected $user = 'john.doe@example.com'; protected $password = 'secret'; }
现在我们可以打电话给EWS:
$client = new ExchangeNTLMSoapClient($wsdl);
但是,这两个回应将失败。 第一个原因是WSDL文件应该包含一个soap:address元素,描述在哪里可以找到SOAP Web服务的位置。 Exchange提供的WSDL文件不包含此类元素。 可能还有其他方法可以做到这一点,但一个解决方案是下载WSDL文件,并在最后添加以下内容:
<wsdl:service name="ExchangeServices"> <wsdl:port name="ExchangeServicePort" binding="tns:ExchangeServiceBinding"> <soap:address location="https://exchange.example.com/EWS/Exchange.asmx"/> </wsdl:port> </wsdl:service> </wsdl:definitions>
这告诉SoapClient在哪里可以找到实际的Web服务。 此解决方案要求WSDL文件引用的两个文件types.xsd和messages.xsd也被下载并放置在本地。 这不是您只联系一台Exchange服务器的问题,但如果您需要联系许多服务器,这不是一个优雅的解决方案。
调用ExchangeNTLMSoapClient将失败的另一个原因是包装器只会添加NTLM支持以初始下载WSDL文件。 当SoapClient继续联系Web服务时,它将切换回基本身份验证。 要解决这个问题,我们创建一个使用CURL的新流对象:
class NTLMStream { private $path; private $mode; private $options; private $opened_path; private $buffer; private $pos; public function stream_open($path, $mode, $options, $opened_path) { echo "[NTLMStream::stream_open] $path , mode=$mode n"; $this->path = $path; $this->mode = $mode; $this->options = $options; $this->opened_path = $opened_path; $this->createBuffer($path); return true; } public function stream_close() { echo "[NTLMStream::stream_close] n"; curl_close($this->ch); } public function stream_read($count) { echo "[NTLMStream::stream_read] $count n"; if(strlen($this->buffer) == 0) { return false; } $read = substr($this->buffer,$this->pos, $count); $this->pos += $count; return $read; } public function stream_write($data) { echo "[NTLMStream::stream_write] n"; if(strlen($this->buffer) == 0) { return false; } return true; } public function stream_eof() { echo "[NTLMStream::stream_eof] "; if($this->pos > strlen($this->buffer)) { echo "true n"; return true; } echo "false n"; return false; } /* return the position of the current read pointer */ public function stream_tell() { echo "[NTLMStream::stream_tell] n"; return $this->pos; } public function stream_flush() { echo "[NTLMStream::stream_flush] n"; $this->buffer = null; $this->pos = null; } public function stream_stat() { echo "[NTLMStream::stream_stat] n"; $this->createBuffer($this->path); $stat = array( 'size' => strlen($this->buffer), ); return $stat; } public function url_stat($path, $flags) { echo "[NTLMStream::url_stat] n"; $this->createBuffer($path); $stat = array( 'size' => strlen($this->buffer), ); return $stat; } /* Create the buffer by requesting the url through cURL */ private function createBuffer($path) { if($this->buffer) { return; } echo "[NTLMStream::createBuffer] create buffer from : $pathn"; $this->ch = curl_init($path); curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($this->ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); curl_setopt($this->ch, CURLOPT_HTTPAUTH, CURLAUTH_NTLM); curl_setopt($this->ch, CURLOPT_USERPWD, $this->user.':'.$this->password); echo $this->buffer = curl_exec($this->ch); echo "[NTLMStream::createBuffer] buffer size : ".strlen($this->buffer)."bytesn"; $this->pos = 0; } }
...和这个流的第二个包装提供NTLMStream的密码:
class ExchangeNTLMStream extends NTLMStream { protected $user = 'john.doe@example.com'; protected $password = 'secret'; }
现在我们需要告诉PHP在调用Web服务时使用这个流:
stream_wrapper_unregister('https'); stream_wrapper_register('https', 'ExchangeNTLMStream') or die("Failed to register protocol"); $wsdl = "/usr/local/www/Services.wsdl"; $client = new ExchangeNTLMSoapClient($wsdl); /* Do something with the web service connection */ stream_wrapper_restore('https');
现在我们和EWS有工作沟通。 我们来做一些事情:
print_r($client->__getFunctions());
这列出了可用的功能。 我们来使用FindItem函数。 它会获取Exchange服务器上特定文件夹中的所有项目。 但是我们如何组成一个请求? 查看函数列表,我们确定它们定义参数的数据类型和返回值。 EWS数据类型相当详细和复杂,有400多种数据类型。 我们来看看这些数据类型是什么样的:
print_r($client->__getTypes());
这将以一般的类C语法描述各个数据类型。
让我们创建一个请求。 MSDN文档有助于确定必需的字段及其可能的值。 首先,我们将列出帐户顶层的文件夹:
$FindFolder->Traversal = "Shallow"; $FindFolder->FolderShape->BaseShape = "Default"; $FindFolder->ParentFolderIds->DistinguishedFolderId->Id = "root"; $result = $client->FindFolder($FindFolder); $folders = $result->ResponseMessages->FindFolderResponseMessage->RootFolder->Folders->Folder; foreach($folders as $folder) { echo $folder->DisplayName."n"; }
现在,我们来查看日历中的所有项目:
$FindItem->Traversal = "Shallow"; $FindItem->ItemShape->BaseShape = "AllProperties"; $FindItem->ParentFolderIds->DistinguishedFolderId->Id = "calendar"; $FindItem->CalendarView->StartDate = "2008-12-01T00:00:00Z"; $FindItem->CalendarView->EndDate = "2008-12-31T00:00:00Z"; $result = $client->FindItem($FindItem); $calendaritems = $result->ResponseMessages->FindItemResponseMessage->RootFolder->Items->CalendarItem; foreach($calendaritems as $item) { echo $item->Subject."n"; }
这给了我们2008年12月所有John Doe日历项目的列表。现在我们删除此列表中的所有项目。 为此,我们需要所有项目的Id和ChangeKey:
$ids = array(); $changeKeys = array(); foreach($calendaritems as $item) { $ids[] = $item->ItemId->Id; $changeKeys[] = $item->ItemId->ChangeKey; } if(sizeof($ids) > 0) { $DeleteItem->DeleteType = "HardDelete"; $DeleteItem->SendMeetingCancellations = "SendToNone"; $DeleteItem->ItemIds->ItemId = array(); for($i = 0; $i < sizeof($ids); $i++ ) { $DeleteItem->ItemIds->ItemId[$i]->Id = $ids[$i]; $DeleteItem->ItemIds->ItemId[$i]->ChangeKey = $changeKeys[$i]; } $result = $client->DeleteItem($DeleteItem); print_r($result); }
最后,让我们在日历中创建一个新的项目:
$CreateItem->SendMeetingInvitations = "SendToNone"; $CreateItem->SavedItemFolderId->DistinguishedFolderId->Id = "calendar"; $CreateItem->Items->CalendarItem = array(); for($i = 0; $i < 1; $i++) { $CreateItem->Items->CalendarItem[$i]->Subject = "Hello from PHP"; $CreateItem->Items->CalendarItem[$i]->Start = "2010-01-01T16:00:00Z"; # ISO date format. Z denotes UTC time $CreateItem->Items->CalendarItem[$i]->End = "2010-01-01T17:00:00Z"; $CreateItem->Items->CalendarItem[$i]->IsAllDayEvent = false; $CreateItem->Items->CalendarItem[$i]->LegacyFreeBusyStatus = "Busy"; $CreateItem->Items->CalendarItem[$i]->Location = "Bahamas"; $CreateItem->Items->CalendarItem[$i]->Categories->String = "MyCategory"; } $result = $client->CreateItem($CreateItem); print_r($result);
本教程中使用的对象还有许多其他功能和许多其他属性。
高级
如果您需要使用例如函数来扩展在WSDL中定义的类,则可以使用NTLMSoapClient类来实现。 将一个构造函数添加到将WSDL类注册为PHP类的类中:
function __construct($wsdl, $options = null) { $client = new NTLMSoapClient($wsdl, $options); $types = array(); foreach($client->__getTypes() as $type) { # Match the type information using a regular expession preg_match("/([a-z0-9_]+)s+([a-z0-9_]+([])?)(.*)?/si", $type, $matches); $qualifier = $matches[1]; $name = $matches[2]; if($qualifier == "struct") { # Store the data type information in an array for later use in the classmap $types[$name] = $name; # Check that the class does not exsit before creating it. We only need to create empty classes. if (! class_exists($name)) { eval("class $name {}"); } else { echo "[ExchangeNTLMSoapClient::__construct] Class $name already exists.n"; } } } # Add the classmap to the options array and call the parent constructor if(is_null($options)) { $options = array(); } $options['classmap'] = $types; parent::__construct($wsdl, $options); }
这将为PHP脚本中尚未定义的类加载空类定义。 现在可以定义一个覆盖自动加载的类的类:
class EmailAddressDictionaryEntryType { function validate() { # Lame email validator return stristr("@", $this->Value); } }
最后
就这样。 从示例脚本到Outlook替换还有很长的路要走,但是对于例如集成和数据迁移来说,这可能非常有用。
感谢Thomas Rabaix在SOAP和PHP中关于NTLM认证的文章: http : //rabaix.net/en/articles/2008/03/13/using-soap-php-with-ntlm-authentication 。 感谢Adam Delves关于WSDL和PHP的文章: http : //www.phpbuilder.com/columns/adam_delves20060606.php3 。