Например, существует удаленный API со следующими вызовами:
getGroupCapacity(group)
setGroupCapacity(group, quantity)
getNumberOfItemsInGroup(group)
addItemToGroup(group, item)
deleteItemFromGroup(group, item)
Задача состоит в том, чтобы добавить некоторый объект к некоторой группе. У групп есть способность. Таким образом, сначала мы должны проверить, не ли группа полна. Если это, способность увеличения, то добавьте объект. Что-то вроде этого (например, API выставляется с SOAP):
function add_item($group, $item) {
$soap = new SoapClient(...);
$capacity = $soap->getGroupCapacity($group);
$itemsInGroup = $soap->getNumberOfItemsInGroup($group);
if ($itemsInGroup == $capacity) {
$soap->setGroupCapacity($group, $capacity + 1);
}
$soap->addItemToGroup($group, $item);
}
Теперь, что, если неудавшийся addItemToGroup (объект был плох)? Мы должны откатывать способность группы.
Теперь предположите, что необходимо добавить 10 объектов, чтобы сгруппировать и затем установить добавленные объекты с некоторыми свойствами - и все это в единственной транзакции. Это означает, перестало ли это работать где-нибудь в середине, необходимо откатывать все к предыдущему состоянию.
Действительно ли это возможно без набора и запутанного кода IF? Какая-либо библиотека, платформа, шаблон или решение архитектуры, которое упростит такие операции (в PHP)?
UPD: SOAP является просто примером. Решение должно соответствовать любому сервису, даже необработанному TCP. Основной момент вопроса - то, как организовать транзактное поведение с базовым нетранзакционным API.
UPD2: Я предполагаю, что эта проблема симпатична то же на всех языках программирования. Таким образом, любые ответы одобрены, не только PHP.
Заранее спасибо!
<?php
//
// Obviously better if the service supports transactions but here's
// one possible solution using the Command pattern.
//
// tl;dr: Wrap all destructive API calls in IApiCommand objects and
// run them via an ApiTransaction instance. The IApiCommand object
// provides a method to roll the command back. You needn't wrap the
// non-destructive commands as there's no rolling those back anyway.
//
// There is one major outstanding issue: What do you want to do when
// an API command fails during a rollback? I've marked those areas
// with XXX.
//
// Barely tested but the idea is hopefully useful.
//
class ApiCommandFailedException extends Exception {}
class ApiCommandRollbackFailedException extends Exception {}
class ApiTransactionRollbackFailedException extends Exception {}
interface IApiCommand {
public function execute();
public function rollback();
}
// this tracks a history of executed commands and allows rollback
class ApiTransaction {
private $commandStack = array();
public function execute(IApiCommand $command) {
echo "EXECUTING " . get_class($command) . "\n";
$result = $command->execute();
$this->commandStack[] = $command;
return $result;
}
public function rollback() {
while ($command = array_pop($this->commandStack)) {
try {
echo "ROLLING BACK " . get_class($command) . "\n";
$command->rollback();
} catch (ApiCommandRollbackFailedException $rfe) {
throw new ApiTransactionRollbackFailedException();
}
}
}
}
// this groups all the api commands required to do your
// add_item function from the original post. it demonstrates
// a nested transaction.
class AddItemToGroupTransactionCommand implements IApiCommand {
private $soap;
private $group;
private $item;
private $transaction;
public function __construct($soap, $group, $item) {
$this->soap = $soap;
$this->group = $group;
$this->item = $item;
}
public function execute() {
try {
$this->transaction = new ApiTransaction();
$this->transaction->execute(new EnsureGroupAvailableSpaceCommand($this->soap, $this->group, 1));
$this->transaction->execute(new AddItemToGroupCommand($this->soap, $this->group, $this->item));
} catch (ApiCommandFailedException $ae) {
throw new ApiCommandFailedException();
}
}
public function rollback() {
try {
$this->transaction->rollback();
} catch (ApiTransactionRollbackFailedException $e) {
// XXX: determine if it's recoverable and take
// appropriate action, e.g. wait and try
// again or log the remaining undo stack
// for a human to look into it.
throw new ApiCommandRollbackFailedException();
}
}
}
// this wraps the setgroupcapacity api call and
// provides a method for rolling back
class EnsureGroupAvailableSpaceCommand implements IApiCommand {
private $soap;
private $group;
private $numItems;
private $previousCapacity;
public function __construct($soap, $group, $numItems=1) {
$this->soap = $soap;
$this->group = $group;
$this->numItems = $numItems;
}
public function execute() {
try {
$capacity = $this->soap->getGroupCapacity($this->group);
$itemsInGroup = $this->soap->getNumberOfItemsInGroup($this->group);
$availableSpace = $capacity - $itemsInGroup;
if ($availableSpace < $this->numItems) {
$newCapacity = $capacity + ($this->numItems - $availableSpace);
$this->soap->setGroupCapacity($this->group, $newCapacity);
$this->previousCapacity = $capacity;
}
} catch (SoapException $e) {
throw new ApiCommandFailedException();
}
}
public function rollback() {
try {
if (!is_null($this->previousCapacity)) {
$this->soap->setGroupCapacity($this->group, $this->previousCapacity);
}
} catch (SoapException $e) {
throw new ApiCommandRollbackFailedException();
}
}
}
// this wraps the additemtogroup soap api call
// and provides a method to roll the changes back
class AddItemToGroupCommand implements IApiCommand {
private $soap;
private $group;
private $item;
private $complete = false;
public function __construct($soap, $group, $item) {
$this->soap = $soap;
$this->group = $group;
$this->item = $item;
}
public function execute() {
try {
$this->soap->addItemToGroup($this->group, $this->item);
$this->complete = true;
} catch (SoapException $e) {
throw new ApiCommandFailedException();
}
}
public function rollback() {
try {
if ($this->complete) {
$this->soap->removeItemFromGroup($this->group, $this->item);
}
} catch (SoapException $e) {
throw new ApiCommandRollbackFailedException();
}
}
}
// a mock of your api
class SoapException extends Exception {}
class MockSoapClient {
private $items = array();
private $capacities = array();
public function addItemToGroup($group, $item) {
if ($group == "group2" && $item == "item1") throw new SoapException();
$this->items[$group][] = $item;
}
public function removeItemFromGroup($group, $item) {
foreach ($this->items[$group] as $k => $i) {
if ($item == $i) {
unset($this->items[$group][$k]);
}
}
}
public function setGroupCapacity($group, $capacity) {
$this->capacities[$group] = $capacity;
}
public function getGroupCapacity($group) {
return $this->capacities[$group];
}
public function getNumberOfItemsInGroup($group) {
return count($this->items[$group]);
}
}
// nested transaction example
// mock soap client is hardcoded to fail on the third additemtogroup attempt
// to show rollback
try {
$soap = new MockSoapClient();
$transaction = new ApiTransaction();
$transaction->execute(new AddItemToGroupTransactionCommand($soap, "group1", "item1"));
$transaction->execute(new AddItemToGroupTransactionCommand($soap, "group1", "item2"));
$transaction->execute(new AddItemToGroupTransactionCommand($soap, "group2", "item1"));
$transaction->execute(new AddItemToGroupTransactionCommand($soap, "group2", "item2"));
} catch (ApiCommandFailedException $e) {
$transaction->rollback();
// XXX: if the rollback fails, you'll need to figure out
// what you want to do depending on the nature of the failure.
// e.g. wait and try again, etc.
}
Вы можете инкапсулировать отдельные запросы SOAP в классы, выбрасывая соответствующие исключения.
Более грязным решением было бы создать массив исключений и вручную добавить queryStatus = false или queryStatus = true на каждый шаг, а затем проверить, действительна ли предложенная транзакция. Если это так, вы вызываете последний метод commitTransaction.
Удаленные службы обычно не поддерживают транзакции. Я не знаю PHP, но в BPEL есть нечто, называемое Compensation
.
Компенсация, или отмена шагов бизнес-процесса, которые уже завершились успешно, - одна из важнейших концепций бизнес-процессов. Цель компенсации состоит в том, чтобы обратить вспять последствия предыдущих действий, которые были выполнены как часть бизнес-процесса, от которого отказываются.
Возможно, вы могли бы попробовать что-то подобное. Будут некоторые if / else.
Теоретически, один из семейства протоколов WS-DeathStar, а именно WS-Transaction , как раз этим занимается. Однако мне неизвестно (хотя я не являюсь разработчиком PHP) ни одной реализации этого стандарта в PHP.
Похоже, вам нужны транзакции и / или блокировка, как база данных. ваш клиентский код будет выглядеть примерно так:
function add_item($group, $item) { $soap = new SoapClient(...); $transaction = $soap->startTransaction(); # or: # $lock = $soap->lockGroup($group, "w"); # strictly to prevent duplication of the rest of the code: # $transaction = $lock; $capacity = $soap->getGroupCapacity($transaction, $group); $itemsInGroup = $soap->getNumberOfItemsInGroup($transaction, $group); if ($itemsInGroup == $capacity) { $soap->setGroupCapacity($transaction, $group, $capacity + 1); } $soap->addItemToGroup($transaction, $group, $item); $transaction->commit(); # or: $lock->release(); }
Конечно, вам нужно будет обрабатывать некорректно работающие клиенты, такие как те, которые вылетают перед фиксацией / освобождением, или те, которые блокируются слишком сильно, что приводит к излишнему отказу других клиентов. Это возможно при бездействии и максимальных тайм-аутах, а также при максимальном количестве блокировок на одного клиента.
Грегор Хоуп написал хорошее резюме различных подходов к удаленной обработке ошибок:
Ваша кофейня не использует двухэтапную фиксацию
Вкратце:
Однако в вашем случае может оказаться, что удаленный API слишком мелкозернистый. Вам действительно нужен setGroupCapacity
как отдельный сервис? Как насчет того, чтобы просто предоставить addUserToGroup
и позволить сервису обрабатывать любое необходимое увеличение емкости внутри? Таким образом, вся транзакция может содержаться в одном вызове службы.
Ваш текущий API также открывается для проблем параллелизма и условий гонки. Что, если между вызовом getNumberOfItemsInGroup
и setGroupCapacity
какой-то другой поток удастся добавить пользователя? Ваш запрос не будет выполнен, потому что другой поток «украл» увеличение вашей емкости.
Поместите логику транзакции на удаленную сторону. setGroupCapacity() должна быть инкапсулирована в addItemToGroup(). Это внутреннее состояние, о котором вызывающая сторона не должна беспокоиться. С его помощью вы можете добавлять элемент за элементом и легко разворачивать его с помощью deleteItemFromGroup().
Если вы вынуждены жить с API низкого уровня, то откат зависит от того, будете ли вы отслеживать поток своих действий.