複雑さはどこにあるのか。パート2 (2024/04/25)

複雑さはどこにあるのか。パート2 (2024/04/25)

https://blogs.oracle.com/database/post/where-is-the-complexity-part-2

投稿者: Todd Little | Chief Architect, Transaction Processing Products


はじめに


前のブログ記事で、あるアカウントから別のアカウントに資金を振り替える簡単なTellerマイクロサービスが、Spring BootでRESTサービスとしてどのように実装できるかを説明しました。私がカバーしなかったのは、このような単純なアプリケーションが失敗する可能性があるすべての方法でした。実施できるカップルがいました。ただし、ネットワーク障害、サービス障害、データベース障害などの様々な障害のこの問題を解決するために、一般的なSagaパターンなどの分散トランザクション・パターンを使用できます。この記事では、Eclipse MicroProfileの長時間実行アクションを活用するために、これらのマイクロサービスの単純な最小またはエラー処理バージョンから移行するために必要なものを確認します。この記事を読んで学ぶように、Sagasを追加すると、アプリケーション・コードがかなり複雑になります。分散トランザクション・パターンを使用することが理想的には、より複雑にするのではなく、開発者の生活を簡素化する必要があります。引き続き、開発者がSagasの使用にどれだけの複雑さがかかっているかをご覧ください。


Eclipse MicroProfile Long Running Actions (LRA)でSagaパターンを使用するには、開発者は、解決されるビジネス上の問題とは本質的に無関係な情報を追跡する必要があります。このユースケースにおけるLRAの基本的なフローは次のとおりです。





  1. テラー・マイクロサービスは、部門1の口座Aから部門2の口座BにXを振り替えるためにコールされます
  2. テラー・マイクロサービスがLRAを開始します。これにより、後続の呼出しにLRAヘッダーが含まれるようになります。
  3. テラー・マイクロサービスによるアカウントAのdepartment1への取下げ
    1. カバーの下で、部門1はLRAヘッダーを確認し、LRAコーディネータに登録してLRAに参加します。
    2. 部門1は勘定科目AからXを取り下げ、テラー・マイクロサービスに戻ります
  4. テラーマイクロサービスは口座Bの部門2の預金を呼び出します
    1. カバーの下では、部門2はLRAヘッダーを確認し、LRAコーディネータに登録してLRAに参加します。
    2. 部門2はXを勘定科目Bに預け入れ、テラー・マイクロサービスに戻ります
  5. テラー・マイクロサービスがLRAをクローズする原因
    1. 各参加者の完全なエンドポイントにコールバックするLRAコーディネータ
    2. 各参加者の完全なエンドポイントは、後でLRAの最終ステップを実行します。
  6. テラーマイクロサービスが成功を返す



エラーの処理


発生する可能性のある様々な障害を処理するために、次の追加ステップを実行する必要がある場合があります。


  1. 取下げ要求が失敗した場合、テラーはLRAを取り消し、失敗をその呼出し元に返す必要があります。
    1. これにより、LRAコーディネータは、報酬エンドポイントで登録された各参加者にコールバックされます。この時点でイニシエータのみになります。
    2. イニシエータは、定義されていればその補正エンドポイントでコールバックされ、それでは何もできません。テラーが状態を保持しないため、テラーでLRAの補正/完了を行うことは何もないため、サンプル・コードでは補正コールバックまたは完了コールバックは定義されません。
  2. 同様に、口座Bに対する預入要求が失敗した場合、テラーはLRAを取り消す必要があります。
    1. これにより、LRAコーディネータは、報酬エンドポイントで登録された各加入者にコールバックされます。この時点で、イニシエータおよび部門1のみである可能性があります。また、デポジットに失敗する前にデパートメント2に参加する機会があった場合、デパートメント2も含まれる場合があります。
      1. イニシエータは、定義されていればその補正エンドポイントでコールバックされ、何もできません
      2. 部門1はその補正エンドポイントで呼び出され、その時点で、以前の撤回操作を補う方法を理解する必要があります。そこが楽しみの始まりです。これについては近々!
      3. 部門2は、その補正エンドポイントでコールバックされ、部門1と同じドリルを通過することもできます。
  3. 出金と入金の両方が成功した場合、担当者はLRAをクローズする必要があります。これにより、LRAコーディネータは次のようになります。
    1. テラーの完了エンドポイントを呼び出す
      1. 現時点では、担当者はまったく関係ありません。
    2. 部門1の完了エンドポイントの呼出し
      1. 部門1の完了エンドポイントは、LRAにある経理情報をクリーンアップする必要があります。
    3. 部門2の完了エンドポイントの呼出し
      1. 部門2の完了エンドポイントは、LRAにある経理情報をクリーンアップする必要があります。


そして完了です!さて、一種の。楽しみの始まりと経理について、もっと詳しく見ていきましょう。参加者が完全なエンドポイントまたは補正エンドポイントでコールされると、渡されるのはRESTヘッダーのLRA IDのみです。トランザクションを完了または補正する手段は、開発者が提供するアプリケーション・コードに完全に依存します。そのため、通常、これは、LRAの一部として行われた変更を記録するために何らかのログまたはジャーナルを作成して、マイクロサービスがトランザクションを完了または補正するために何をする必要があるかを認識することを意味します。


TransferResource.javaから更新されたテラー・マイクロサービス・コードは、次のようになります。


@Autowired
    @Qualifier("MicroTxLRA")
    RestTemplate restTemplate;

    @Value("${departmentOneEndpoint}")
    String departmentOneEndpoint;

    @Value("${departmentTwoEndpoint}")
    String departmentTwoEndpoint;

    @RequestMapping(value = "transfer", method = RequestMethod.POST)
    @LRA(value = LRA.Type.REQUIRES_NEW, end = true, cancelOnFamily = {HttpStatus.Series.CLIENT_ERROR, HttpStatus.Series.SERVER_ERROR})
    public ResponseEntity<?> transfer(@RequestBody Transfer transferDetails,
                                      @RequestHeader(LRA_HTTP_CONTEXT_HEADER) String lraId) {

        LOG.info("Transfer initiated: {}", transferDetails);
        try {
            ResponseEntity<String> withdrawResponse = withdraw(transferDetails.getFrom(), transferDetails.getAmount());
            if (!withdrawResponse.getStatusCode().is2xxSuccessful()) {
                LOG.error("Withdraw failed: {} Reason: {}", transferDetails, withdrawResponse.getBody());
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                        .body(new TransferResponse("Withdraw failed. " + withdrawResponse.getBody()));
            }

            ResponseEntity<String> depositResponse = deposit(transferDetails.getTo(), transferDetails.getAmount());
            if (!depositResponse.getStatusCode().is2xxSuccessful()) {
                LOG.error("Deposit failed: {} Reason: {} ", transferDetails, depositResponse.getBody());
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                        .body(new TransferResponse("Deposit failed"));
            }
        } catch (Exception e) {
            LOG.error("Transfer failed with exception {}", e.getLocalizedMessage());
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(new TransferResponse("Transfer failed. " + e.getLocalizedMessage()));
        }
        LOG.info("Transfer successful: {}", transferDetails);
        return ResponseEntity
                .ok(new TransferResponse("Transfer completed successfully"));
    }


RestTemplateを、MicroTxトランザクション・ヘッダーの受渡しなどを自動化するフィルタを提供するMicroTxによって提供されるものに切り替えたことがわかります。@LRA注釈を追加して、新しいLRAを起動する必要があることを示します。また、LRAは最終的に部門1の補正エンドポイントをコールすることで是正措置が行われるようにするため、redepositWithdrawnAmountメソッドを削除しました。最後に、LRA注釈のend = trueオプションにより、転送メソッドが完了すると、LRAは自動的にクローズされます(または、戻りステータスがサーバーまたはクライアント・エラーの場合は取り消されます(4xxまたは5xx)。


これまでのところ、変更は最小限です。部門預金および引出しサービスに対してどのような変更を行う必要があるかを見てみましょう。最初のブログ投稿では、これらのサービスはAccountResource.javaファイルに含まれていました。この記事では、そのクラスを3つの個別のクラス(AccountAdminService、DepositServiceおよびWithdrawService)に分割しました。これは、引出しまたは預入サービスに固有の完了コールバックまたは報酬コールバック(あるいはその両方)を登録するためです。


WithdrawService.javaでの取下げサービスの実装方法の変更を見てみましょう。


@RestController
@RequestMapping("/withdraw")
public class WithdrawService {

    private static final Logger LOG = LoggerFactory.getLogger(WithdrawService.class);

    @Autowired
    IAccountOperationService accountService;

    @Autowired
    JournalRepository journalRepository;

    @Autowired
    AccountTransferDAO accountTransferDAO;

    @Autowired
    IAccountQueryService accountQueryService;

    /**
     * cancelOnFamily attribute in @LRA is set to empty array to avoid cancellation from participant.
     * As per the requirement, only initiator can trigger cancel, while participant returns right HTTP status code to initiator
     */
    @RequestMapping(value = "/{accountId}", method = RequestMethod.POST)
    @LRA(value = LRA.Type.MANDATORY, end = false, cancelOnFamily = {})
    public ResponseEntity<?> withdraw(@RequestHeader(LRA_HTTP_CONTEXT_HEADER) String lraId,
                                      @PathVariable("accountId") String accountId, @RequestParam("amount") double amount) {
        try {
            this.accountService.withdraw(accountId, amount);
            accountTransferDAO.saveJournal(new Journal(JournalType.WITHDRAW.name(), accountId, amount, lraId, ParticipantStatus.Active.name()));
            LOG.info(amount + " withdrawn from account: " + accountId);
            return ResponseEntity.ok("Amount withdrawn from the account");
        } catch (NotFoundException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
        } catch (UnprocessableEntityException e) {
            LOG.error(e.getLocalizedMessage());
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(e.getMessage());
        } catch (Exception e) {
            LOG.error(e.getLocalizedMessage());
            return ResponseEntity.internalServerError().body(e.getLocalizedMessage());
        }
    }


ご覧のとおり、引き出しサービスは、LRAによる変更を追跡するためにジャーナルを導入する必要があるため、より複雑になりました。我々は、この日記を保持しているので、我々は、要求を補償する方法を知るだろう。取下げサービスは、アカウント残高を即時に更新し、仕訳の変更を追跡します。


/**
     * Update LRA state. Do nothing else.
     */
    @RequestMapping(value = "/complete", method = RequestMethod.PUT)
    @Complete
    public ResponseEntity<?> completeWork(@RequestHeader(LRA_HTTP_CONTEXT_HEADER) String lraId) {
        LOG.info("withdraw complete called for LRA : " + lraId);
        Journal journal = accountTransferDAO.getJournalForLRAid(lraId, JournalType.WITHDRAW);
        if (journal != null) {
            String lraState = journal.getLraState();
            if (lraState.equals(ParticipantStatus.Completing.name()) ||
                    lraState.equals(ParticipantStatus.Completed.name())) {
                // idempotency : if current LRA stats is already Completed, do nothing
                return ResponseEntity.ok(ParticipantStatus.valueOf(lraState));
            }
            journal.setLraState(ParticipantStatus.Completed.name());
            accountTransferDAO.saveJournal(journal);
        }
        return ResponseEntity.ok(ParticipantStatus.Completed.name());
    }

    /**
     * Read the journal and increase the balance by the previous withdrawal amount before the LRA
     */
    @RequestMapping(value = "/compensate", method = RequestMethod.PUT)
    @Compensate
    public ResponseEntity<?> compensateWork(@RequestHeader(LRA_HTTP_CONTEXT_HEADER) String lraId) {
        LOG.info("Account withdraw compensate() called for LRA : " + lraId);
        try {
            Journal journal = accountTransferDAO.getJournalForLRAid(lraId, JournalType.WITHDRAW);
            if (journal != null) {
                String lraState = journal.getLraState();
                if (lraState.equals(ParticipantStatus.Compensating.name()) ||
                        lraState.equals(ParticipantStatus.Compensated.name())) {
                    // idempotency : if current LRA stats is already Compensated, do nothing
                    return ResponseEntity.ok(ParticipantStatus.valueOf(lraState));
                }
                accountTransferDAO.doCompensationWork(journal);
            } else {
                LOG.warn("Journal entry does not exist for LRA : {} ", lraId);
            }
            return ResponseEntity.ok(ParticipantStatus.Compensated.name());
        } catch (Exception e) {
            LOG.error("Compensate operation failed : " + e.getMessage());
            return ResponseEntity.ok(ParticipantStatus.FailedToCompensate.name());
        }
    }

    @RequestMapping(value = "/status", method = RequestMethod.GET)
    @Status
    public ResponseEntity<?> status(@RequestHeader(LRA_HTTP_CONTEXT_HEADER) String lraId,
                                    @RequestHeader(LRA_HTTP_PARENT_CONTEXT_HEADER) String parentLRA) throws Exception {
        return accountTransferDAO.status(lraId, JournalType.WITHDRAW);
    }

    /**
     * Delete journal entry for LRA (or keep for auditing)
     */
    @RequestMapping(value = "/after", method = RequestMethod.PUT)
    @AfterLRA
    public ResponseEntity<?> afterLRA(@RequestHeader(LRA_HTTP_ENDED_CONTEXT_HEADER) String lraId, @RequestBody String status) {
        LOG.info("After LRA called for lraId : {} with status {} ", lraId, status);
        accountTransferDAO.afterLRA(lraId, status, JournalType.WITHDRAW);
        return ResponseEntity.ok().build();
    }


WithdrawServiceクラスの残りの部分は、LRAの完了または補正を処理し、部門のLRAステータスの表示を許可し、LRAの完了または補正時にコールバックを登録します。LRAが取り消され、報酬エンドポイントがコーディネータによってコールされると、取下げ額が再デポされ、そのLRA状態が補償済に設定されます。LRAが完了すると、資金がすでに取り下げられコミットされているため、完了エンドポイントが呼び出されます。したがって、そのLRA状態のみが更新されます。LRAが最終的に完了または補正されると、コーディネータによってアフター・コールバックが行われます。この場合、コールバックは、不要になった仕訳を単純に削除します。


デポジット・サービスDepositService.javaを見てみましょう:


@RestController
@RequestMapping("/deposit")
public class DepositService {

    private static final Logger LOG = LoggerFactory.getLogger(DepositService.class);

    @Autowired
    IAccountOperationService accountService;

    @Autowired
    JournalRepository journalRepository;

    @Autowired
    AccountTransferDAO accountTransferDAO;

    /**
     * cancelOnFamily attribute in @LRA is set to empty array to avoid cancellation from participant.
     * As per the requirement, only initiator can trigger cancel, while participant returns right HTTP status code to initiator
     */
    @RequestMapping(value = "/{accountId}", method = RequestMethod.POST)
    @LRA(value = Type.MANDATORY, end = false, cancelOnFamily = {})
    public ResponseEntity<?> deposit(@RequestHeader(LRA_HTTP_CONTEXT_HEADER) String lraId,
                                     @PathVariable("accountId") String accountId, @RequestParam("amount") double amount) {
        accountTransferDAO.saveJournal(new Journal(JournalType.DEPOSIT.name(), accountId, amount, lraId, ParticipantStatus.Active.name()));
        return ResponseEntity.ok("Amount deposited to the account");
    }

    /**
     * Increase balance amount as recorded in journal during deposit call.
     * Update LRA state to ParticipantStatus.Completed.
     */
    @RequestMapping(value = "/complete", method = RequestMethod.PUT)
    @Complete
    public ResponseEntity<?> completeWork(@RequestHeader(LRA_HTTP_CONTEXT_HEADER) String lraId) {
        try {
            LOG.info("deposit complete called for LRA : " + lraId);
            Journal journal = accountTransferDAO.getJournalForLRAid(lraId, JournalType.DEPOSIT);
            if (journal != null) {
                String lraState = journal.getLraState();
                if (lraState.equals(ParticipantStatus.Completing.name()) ||
                        lraState.equals(ParticipantStatus.Completed.name())) {
                    // idempotency : if current LRA stats is already Completed, do nothing
                    return ResponseEntity.ok(ParticipantStatus.valueOf(lraState));
                }
                accountTransferDAO.doCompleteWork(journal);
            } else {
                LOG.warn("Journal entry does not exist for LRA : {} ", lraId);
            }
            return ResponseEntity.ok(ParticipantStatus.Completed.name());
        } catch (Exception e) {
            LOG.error("Complete operation failed : " + e.getMessage());
            return ResponseEntity.ok(ParticipantStatus.FailedToComplete.name());
        }
    }


doCompleteWorkメソッドは次のようになります。


public void doCompleteWork(Journal journal) throws Exception {
        try {
            Account account = accountQueryService.getAccountDetails(journal.getAccountId());
            account.setAmount(account.getAmount() + journal.getJournalAmount());
            accountService.save(account);
            journal.setLraState(ParticipantStatus.Completed.name());
            journalRepository.save(journal);
        } catch (Exception e) {
            journal.setLraState(ParticipantStatus.FailedToComplete.name());
            journalRepository.save(journal);
            throw new Exception("Failed to complete", e);
        }
    }


最後に、口座に振り込まれた金額を追加します。預入の補正コールバックを見ると、仕訳のLRAステータスを補正済に設定するだけです。


/**
     * Update LRA state to ParticipantStatus.Compensated.
     */
    @RequestMapping(value = "/compensate", method = RequestMethod.PUT)
    @Compensate
    public ResponseEntity<?> compensateWork(@RequestHeader(LRA_HTTP_CONTEXT_HEADER) String lraId) {
        LOG.info("Account deposit compensate() called for LRA : " + lraId);
        Journal journal = accountTransferDAO.getJournalForLRAid(lraId, JournalType.DEPOSIT);
        if (journal != null) {
            String lraState = journal.getLraState();
            if (lraState.equals(ParticipantStatus.Compensating.name()) ||
                    lraState.equals(ParticipantStatus.Compensated.name())) {
                // idempotency : if current LRA stats is already Compensated, do nothing
                return ResponseEntity.ok(ParticipantStatus.valueOf(lraState));
            }
            journal.setLraState(ParticipantStatus.Compensated.name());
            accountTransferDAO.saveJournal(journal);
        }
        return ResponseEntity.ok(ParticipantStatus.Compensated.name());
    }

    @RequestMapping(value = "/status", method = RequestMethod.GET)
    @Status
    public ResponseEntity<?> status(@RequestHeader(LRA_HTTP_CONTEXT_HEADER) String lraId,
                                    @RequestHeader(LRA_HTTP_PARENT_CONTEXT_HEADER) String parentLRA) throws Exception {
        return accountTransferDAO.status(lraId, JournalType.DEPOSIT);
    }


最後に、afterコールバックがトリガーされ、accountTransferDAOでafterLRAメソッドをコールして、関連するジャーナル・エントリが削除されます。


/**
     * Delete journal entry for LRA (or keep for auditing)
     */
    @RequestMapping(value = "/after", method = RequestMethod.PUT)
    @AfterLRA
    public ResponseEntity<?> afterLRA(@RequestHeader(LRA_HTTP_ENDED_CONTEXT_HEADER) String lraId, @RequestBody String status) {
        LOG.info("After LRA Called : " + lraId);
        accountTransferDAO.afterLRA(lraId, status, JournalType.DEPOSIT);
        return ResponseEntity.ok().build();
    }


accountTransferDAO afterLRAメソッドは、実際にジャーナル・エントリを削除します。


public void afterLRA(String lraId, String lraStatus, JournalType journalType){
        Journal journal = getJournalForLRAid(lraId, journalType);
        if (Objects.nonNull(journal) && isLRASuccessfullyEnded(lraStatus)) {
            journalRepository.delete(journal);
        }
    }


前述のコードが示すように、アプリケーション開発者はデータの一貫性を確保するために、多くの追加作業が必要です。ただし、これらのすべての変更であっても、前述のコードは、同じLRAで預入または取下げを行う2つのコールを処理しません。仕訳実装では、最終更新のみが記録されます。


次の投稿では、同じサンプル・アプリケーションを示しますが、今回はXAを使用してデータの一貫性を確保します。


コメント

このブログの人気の投稿

Oracle RACによるメンテナンスのためのドレインとアプリケーション・コンティニュイティの仕組み (2023/11/01)

Oracle Cloud Infrastructure Secure Desktopsを発表: デスクトップ仮想化のためのOracleのクラウドネイティブ・サービス (2023/06/28)

新しいOracle Container Engine for Kubernetesの機能強化により、大規模なKubernetesがさらに簡単になりました (2023/03/20)