Вопрос о дизайне/Архитектуре: откаты с удаленными сервисами

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

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

5
задан Qwerty 17 February 2010 в 09:23
поделиться

7 ответов

<?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.
}
4
ответ дан 14 December 2019 в 13:34
поделиться

PHP-исключения

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

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

0
ответ дан 14 December 2019 в 13:34
поделиться

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

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

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

1
ответ дан 14 December 2019 в 13:34
поделиться

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

0
ответ дан 14 December 2019 в 13:34
поделиться

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

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
ответ дан 14 December 2019 в 13:34
поделиться

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

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

Вкратце:

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

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

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

0
ответ дан 14 December 2019 в 13:34
поделиться

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

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

1
ответ дан 14 December 2019 в 13:34
поделиться
Другие вопросы по тегам:

Похожие вопросы: