Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
56
Help us understand the problem. What are the problem?

posted at

updated at

ヘキサゴナルアーキテクチャを使ってドメインモデルをAWS Lambdaファンクションで実装してみた

はじめに

2021年11月20日から21日にかけて、JAWS Pankration 2021 ~Up till Down~が開催されました。筆者はその中で DDD on AWS Lambdaというタイトルのセッションを担当しました。このセッションでは、ヘキサゴナルアーキテクチャを利用してドメインモデルをAWS Lambdaファンクションに実装する方法、また制御の反転(IoC)を利用してユニットテストを容易にする方法について紹介しました。

この記事を書いたのは

今回、JAWS Pankrationは新たな試みとして、広く海外のコミュニティーメンバーにも参加してもらうために、全てのスライドを英語で記述しポケトークを利用して逐次通訳しながらセッションを行いました。海外のコミュニティーメンバーにも見てもらいたかったので、このために実装したサンプルアプリケーションのリポジトリのREADME.mdも英語で記述しましたが、JAWS Pankrationのセッションが終了した後、やはり日本語でもこの内容を紹介したいと思い、この記事で紹介することにしました。この記事を見て興味を持たれた方は、サンプルアプリケーションのGitHubのリポジトリをぜひご覧ください。

JAWS Pankration 2021に登壇した理由

マイクロサービスアーキテクチャが注目され、サービスの分割手法としてドメイン駆動設計への関心が再度高まると共に、自社のサービスにAWS Lambdaを利用した場合にどのようにドメインモデルを実装するか、またユニットテストを容易にするにはどうすれば良いかという質問をされる機会が増えました。そこで自分なりに考えた結果、ヘキサゴナルアーキテクチャの概念を利用してドメインモデルを外部から隔離し、また制御の反転(IoC)を利用することで、クラス間を疎結合にしてユニットテストを容易にするのが良いのではないかと仮説を立て、実際にサンプルアプリケーションを実装してみることにしました。実装してみると個人的には割と良いと感じたので、GitHubにサンプルアプリケーションとして公開し、JAWS Pankration 2021の場でこのアイデアを紹介してみようと思いました。JAWS PankrationにCFPを提出したところ、幸運にも採択されて発表することができました。

JAWS Pankration 2021で発表したトピック

JAWS pankration 2021のセッションでお話ししたトピックは以下の通りです。

ドメイン駆動設計

マイクロサービスアーキテクチャによるサービスの分割方式としてEric Evansドメイン駆動設計が再び注目されています。ドメイン駆動設計はビジネスエキスパートが用いる用語をユビキタス言語として抽出し、ユビキタス言語が共通認識として利用される範囲をコンテキストの境界と位置付け、境界づけされたコンテキストでドメインモデルを定義します。ビジネス要件に対してドメインモデルを設計し、モデルに適切なデータと振る舞いを持たせることで、ドメインエキスパートの知識を正しく実装に反映させることを目的としています。多くの時間をかけてモデルの設計を行って満足し、その後の実装と乖離してしまうのではなく、アジャイル的にドメインモデル、ユニットテスト、実装を繋げて、繰り返し改善し開発していくスタイルが個人的には気に入っています。

ヘキサゴナルアーキテクチャ

ヘキサゴナルアーキテクチャAlistair Cockburnが提唱したアーキテクチャパターンで、ポートとアダプターパターンとも呼ばれます。ヘキサゴナルアーキテクチャでは、アプリケーションを利用するドライバ(アクター)は特定のテクノロジー用のプロシージャ呼び出し、またはメッセージに変換される要求を送信し、それをアプリケーションポートに渡します。アプリケーションはドライバーのテクノロジーについては関知しません。アプリケーションからインフラストラクチャなど外部へ連携する場合は、ポートを介してアダプターに送信します。アダプターは連携先の受信テクノロジーに必要な形式に適切に変換します。
hexagonal_architecture.png

制御の反転(IoC)と依存性の注入(DI)

Robert C.Martinによって提唱された依存関係逆転の原則(DIP)は、上位レベルの実装コードは下位レベルの詳細の実装コードに依存してはいけないというものです。この原則に従ってクラス間の依存を抽象クラスと継承を用いて設計し、また疎結合を実現するために制御の反転(IoC)を行う依存性の注入(DI)を実現するためにPython injectorライブラリを利用しました。

サンプルアプリケーションについて

サンプルアプリケーションはこちらのGitHubリポジトリで確認できます。このサンプルアプリケーションではワクチンの予約システムを例に一部の機能のみを実装しています。ワクチンの予約システムには様々なビジネス要件がありますが、ここでは以下の要件を定義、実装しています。

  • ワクチン接種希望者は、自分のIDと空き時間スロットのIDを指定して予約を行うことができる
  • ワクチン接種希望者は、一人につき2つまで空き時間スロットの予約を取ることができる
  • ワクチン接種希望者は、同一日時のスロットを予約することはできない

これらのビジネス要件をドメインモデルで実装しました。

クラスダイアグラムとシーケンスダイアグラム

サンプルアプリケーションのクラスダイアグラムは以下の通りです。DIPの原則に従い、ポートクラスとアダプタークラスは抽象クラスを継承する形で実装しています。
ReservationReporter-Page-1.drawio.png

またクラス間のシーケンスダイアグラムは以下の通りです。
ReservationReporter-Page-2.drawio.png

クラスの実装

サンプルアプリケーションのクラスはクラスダイアグラムで示したようにドメインモデル、ポート、アダプターに分かれます。また各クラス間はインターフェイスの役割を持つ抽象クラスへ依存する形となっています。Pythonで実装しているため、インターフェイスの代わりに抽象クラスを利用しています。

ドメインモデル(抜粋)

ドメインモデルはシンプルな実装になっています。

from slot import Slot

class Recipient:
    def __init__(self, recipient_id:str, email:str, first_name:str, last_name:str, age:int):
        self.__recipient_id = recipient_id
        self.__email = email
        self.__first_name = first_name
        self.__last_name = last_name
        self.__age = age
        self.__slots = []

    @property
    def recipient_id(self):
        return self.__recipient_id 
...

    def add_reserve_slot(self, slot:Slot) -> bool:        
        if self.are_slots_same_date(slot):
            return False

        if self.is_slot_counts_equal_or_over_two():
            return False

        self.__slots.append(slot)
        slot.use_slot()
        return True

インターフェイスとなる抽象クラス(一部)

インターフェイスとして抽象クラスを利用しています。ユニットテストでは、Amazon DynamoDBへアクセスする具象クラスの代わりに、この抽象クラスを継承するダミークラスを定義しテストを行っています。
以下はドメインモデルがポートクラスを経由してアクセスするアダプタークラスのインターフェイスの例です。

from abc import ABCMeta, abstractmethod
from recipient import Recipient

class IRecipientAdapter(metaclass=ABCMeta):

    @abstractmethod
    def load(self, recipient_id:str) -> Recipient:
        raise NotImplementedError()

    @abstractmethod
    def save(self, recipient:Recipient) -> bool:
        raise NotImplementedError()

インジェクトされるクラス

インジェクトされるクラスは抽象クラスにのみ依存しておりインジェクトされるクラスの実装とは疎結合になっています。この例ではドメインモデルのサービスクラスがポートクラスへアクセスしていますが、ポートクラスの抽象クラスを参照しています。

from injector import inject
from i_recipient_port import IRecipientPort
from i_slot_port import ISlotPort

class ReservationService:
    @inject
    def __init__(self, recipient_port:IRecipientPort, slot_port:ISlotPort):
        self.__recipient_port = recipient_port
        self.__slot_port = slot_port

    def add_reservation(self, recipient_id:str, slot_id:str) -> bool:
        recipient = self.__recipient_port.recipient_by_id(recipient_id)
        slot = self.__slot_port.slot_by_id(slot_id)

        if recipient == None or slot == None:
            return False

        print(f"recipient: {recipient.first_name}, slot date: {slot.reservation_date}")
        ret = recipient.add_reserve_slot(slot)
        if ret == True:
            ret = self.__recipient_port.add_reservation(recipient)
        return ret

Lambdaファンクションのハンドラ

LambdaファンクションのハンドラではDynamoDBへのアクセスを行うコンクリートクラスをインジェクトするためのバインドを定義しています。app.pyはRequestPortクラスのメソッドを経由してドメインモデルのサービスクラスへアクセスします。

class RequestPortModule(Module):
    def configure(self, binder):
        binder.bind(ReservationService, to=ReservationService(
            RecipientPort(DDBRecipientAdapter()), SlotPort(DDBSlotAdapter())))

def lambda_handler(event, context):
    '''
    API Gateway event adapter

    retrieve reservation request parameters
    ex: '{"recipient_id": "1", "slot_id":"1"}'
    '''
    body = json.loads(event['body'])
    recipient_id = body['recipient_id']
    slot_id = body['slot_id']

    injector = Injector([RequestPortModule])
    request_port = injector.get(RequestPort)
    status = request_port.make_reservation(recipient_id, slot_id)

ユニットテストコード(抜粋)

ユニットテストではダミークラスをインジェクトしてテストを実施しています。

import pytest
...

recipient_id = "1"
email = "fatsushi@example.com"
first_name = "Atsushi"
last_name = "Fukui"
age = 30
slot_id = "1"
reservation_date = datetime(2021,12,20, 9, 0, 0)
location = "Tokyo"

# ダミークラス
class DummyRecipientAdapter(IRecipientAdapter):
    def load(self, recipient_id:str) -> Recipient:
        return Recipient(recipient_id, email, first_name, last_name, age)

    def save(self, recipient:Recipient) -> bool:
        return True
...

# インジェクションのバインドの設定
class DummyModule(Module):
    def configure(self, binder):
        binder.bind(ReservationService, to=ReservationService(
            RecipientPort(DummyRecipientAdapter()), SlotPort(DummySlotAdapter())))

# テストターゲットのフィクスチャ
@pytest.fixture()
def fixture_reservation_service():
    injector = Injector([DummyModule])
    reservation_service = injector.get(ReservationService)
    return reservation_service


def test_add_reservation(fixture_reservation_service):
    target = fixture_reservation_service
    recipient_id = "dummy_id"
    slot_id = "dummy_id"

    ret = target.add_reservation(recipient_id, slot_id)
    assert True == ret

サンプルアプリケーションのビルドとデプロイ

GitHubリポジトリからプロジェクトを取得したら以下の手順でビルドとデプロイが可能です。

Serverless Application Model

サンプルプログラムはAWS Serverless Application Model (SAM) でビルド、デプロイするようになっています。このプロジェクトには以下の内容が含まれています。

  • src - アプリケーションのLambdaファンクション
  • events - ファンクションのローカル実行で利用できるイベント
  • tests/unit - アプリケーションコードのユニットテスト
  • template.yaml - アプリケーションのAWSリソースが定義されたSAMテンプレート

ビルドとデプロイ

サンプルアプリケーションをビルド、デプロイするにはSAM CLIを利用します。事前にSAM CLIをインストールしておく必要があります。SAM CLIのインストールの詳細はこちらをご覧ください。

ビルドでデプロイを実行するためには以下のコマンドを実行します。

sam build --use-container
sam deploy --guided

初期データの登録

初期データを登録するAWS CLIを利用したコマンドが用意されています。事前にAWS CLIのインストール、アカウント情報の設定が必要です。以下のshellコマンドを実行します。

chmod +x setup/add_ddb_data.sh
setup/add_ddb_data.sh

SAMビルドとローカルでの実行

Lambdaファンクションをローカルで実行するには以下のコマンドを実行します。

sam build --use-container
sam local invoke HelloWorldFunction --event events/event.json

ユニットテストの実行

このサンプルアプリケーションは、Lambdaファンクションのユニットテストのサンプルにもなっています。ユニットテストを実行するためには、事前にユニットテストに必要なモジュールをインストールします。

pip install -r tests/requirements.txt --user

ユニットテストを実行します。

python -m pytest tests/unit -v

まとめ

ヘキサゴナルアーキテクチャのコンセプトを利用してAWS Lambdaファンクションでドメインモデルを実装してみました。この記事が皆さんにとって少しでも参考になれば幸いです。

本投稿は、個人の意見で、所属する企業や団体は関係ありません。
また掲載しているサンプルプログラム等の動作に関しても一切保証しておりません。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
56
Help us understand the problem. What are the problem?