Вопрос дизайна / архитектуры: откат с удаленными сервисами - PullRequest
5 голосов
/ 15 февраля 2010

Например, есть удаленный 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.

Заранее спасибо!

Ответы [ 7 ]

4 голосов
/ 18 февраля 2010
<?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.
}
1 голос
/ 23 февраля 2010

Разместите логику транзакции на удаленной стороне. setGroupCapacity () должен быть инкапсулирован в addItemToGroup (). Это внутреннее состояние, которое вызывающий абонент не должен беспокоить. При этом вы можете добавлять элемент за элементом и легко раскручивать его с помощью deleteItemFromGroup ().

Если вы должны жить с API низкого уровня, тогда откат зависит от того, отслеживаете ли вы поток действий.

1 голос
/ 17 февраля 2010

Удаленные сервисы обычно не поддерживают транзакции. Я не знаю PHP, но в BPEL у вас есть то, что называется Compensation.

Компенсация или отмена шагов в бизнес-процессе, которые уже успешно завершены, является одним из наиболее важных понятий в бизнес-процессах. Цель компенсации - обратить вспять последствия предыдущих действий, которые были выполнены в рамках бизнес-процесса, от которого отказываются.

Возможно, вы могли бы попробовать что-то подобное. Там будут некоторые, если / еще.

0 голосов
/ 24 февраля 2010

Грегор Хопе (Gregor Hohpe) написал хорошее резюме различных подходов к удаленной обработке ошибок:

Ваша кофейня не использует двухфазную фиксацию

Вкратце:

  • Списание : ничего не делать или отказаться от выполненной работы.
  • Повторить попытку : повторить попытку неисправных частей. Проще, если вы планируете, чтобы ваш сервис был идемпотентным , чтобы он мог запускаться многократно с одним и тем же входом без каких-либо побочных эффектов.
  • Компенсационное действие : предоставьте сервису компенсирующее действие, которое позволит вам отменить работу до сих пор.
  • Координатор транзакций : традиционный двухфазный коммит. Теоретически идеальный, трудновыполнимый на практике, много глючного промежуточного программного обеспечения.

Однако в вашем случае, возможно, удаленный API слишком мелкозернистый. Вам действительно нужно setGroupCapacity в качестве отдельной услуги? Как насчет того, чтобы просто предоставить addUserToGroup и позволить службе справиться с любым необходимым увеличением емкости внутри страны? Таким образом, вся транзакция может содержаться в одном вызове службы.

Ваш текущий API также открыт для проблем параллелизма и условий гонки. Что если между вызовом getNumberOfItemsInGroup и setGroupCapacity какой-то другой поток сможет добавить пользователя? Ваш запрос не будет выполнен, потому что другой поток «украл» увеличение вашей емкости.

0 голосов
/ 18 февраля 2010

Похоже, вам нужны транзакции и / или блокировки, как в базе данных. код вашего клиента будет выглядеть примерно так:

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();
}

Конечно, вам нужно работать с неправильно работающими клиентами, такими как те, которые аварийно завершают работу перед фиксацией / освобождением, или те, которые блокируют слишком много, вызывая ненужные сбои других клиентов. Это возможно при неактивности и максимальных таймаутах, а также при максимальном количестве блокировок на клиента.

0 голосов
/ 17 февраля 2010

Теоретически, один из семейств протоколов "WS-DeathStar", а именно WS-Transaction , имеет дело именно с этим. Однако я не знаю (я не PHP-разработчик) о каких-либо реализациях этого стандарта в PHP.

0 голосов
/ 15 февраля 2010

Исключения PHP

Вы можете инкапсулировать отдельные запросы SOAP в классах, выдавая соответствующие исключения.

Более грязное решение - создать массив исключений и вручную добавить queryStatus = false или queryStatus = true к каждому шагу, а затем проверить допустимость предложенной транзакции. Если это так, вы вызываете окончательный метод commitTransaction.

...