LoginSignup
3
1

pythonとクリーンアーキテクチャで映画,アニメを検索できるLinebotを作ったお話

Last updated at Posted at 2022-12-08

作品紹介

  • ネットフリックスやアマプラと言った媒体を先に選んで、その後ジャンルや公開年数から映画を絞り込みランダムで5つの作品を返すというようなLinebotになっております。
  • ちなみにリンクを踏めば、そのままアプリが起動します。
    gif.gif
  • githubのアカウント

きっかけ

  • 最近よく友達や後輩から,おすすめの映画を聞かれることが増えたのですが...
友人A: 何かおすすめの映画教えてよ〜
わい: んー、どういうジャンルが好き?
友人A: ホラーとかミュージカル系が好き!
わい: んー、じゃあ〇〇とかどう?
友人A: それは、もうみたよ〜
わい: じゃあ、●●とかはどう?
友人A: ありがとう!みてみるね!!

後日

友人A: ねー●●、アマぷらでみれないじゃん!!
わい: そっか、ごめんよ...(いや先に言ってくれ!!)

このような不毛なやり取りをすることが増えて...

しかもおすすめって言葉好きじゃないんですよ!!

スタバのアルバイト中に辛い思いをした言葉ランキング第一位...お兄さんのおすすめのカスタムで!!ですからね...

おすすめってその人のセンスが表れるじゃないですか...だからちょっとハードル高くね?って
(これはわいだけか?)

というわけでこれらを解決するものを作ることにしました。

技術選定

最初に思いついたのはクロスプラットフォームのFlutterとFirebaseを用いたスマホアプリでした。

ただこれアプリ開発経験が乏しいためあんまり自信がなかったってのと、アプリをダウンロードする手間がかかるなっていう発想からやめました

次に思いついたのはNext.jsかNuxt.jsを用いてwebアプリです

これはインターンで使っていたこともあり自信はあったのですが...

わい: ねーねー、アニメのレビュー投稿できるサイト作ったんだ!
友人B: えーすごい!
わい: 使ってみて!!
友人B: 時間があったらね...

結局投稿は自分のものだけでした...(大号泣)
なんやかんやwebアプリは起動するのがめんどくさいですよね...笑

というわけで今回は誰でも簡単に使えるというLiinebotにしました!

  • 以下に使用した技術をまとめます
    • Python(Flask)
      • これは利用したいAPIがPython対応だったため
    • AWS
      • 最初はRenderを使っていたのですが、遅すぎたため...
      • ちなみにApp runnerを使っているため来月しっかり請求がきます
      • 1万を超えている気がします....(ガチで死にたい)
    • Firebase
      • DBはRDBではなくNoSQlを選びました
        • 普通にFirebaseを使ったことがなかったので、使ってみたくてしょうがなかった笑
    • ドメイン駆動設計(クリーンアーキテクチャ)
      • アーキテクチャは勉強という意味も兼ねてクリーンアーキテクチャを実装しました。
      • ここは後ほど解説します。

基本的なロジック

Linebotを作り初めていろんな記事を見てたのですが...、おうむ返しとか1つの質問から情報を返すものはあっても、会話形式でデータを保存していく実装をしている記事ってなかったんですよ...

当たり前なんですが、lineで文字を送信した時点でhttp通信が走ってしまうため前回までの情報を保存しないとサーバー上で1からpyhtonのコードが走ってしまうんですよね...

もちろん1人で使うならコードを更新していく処理を書けばいいのですが、たくさんの人が使うことを考慮するとやっぱりDBは必要になってきますよね...

なので今回は

    def first_question_func(self, event: str, user_id: str, line_bot_api: LineBotApi):
        try:
            # まずは最初にDBのテーブルを作成
            content_type = ContentType("null")
            genre = Genre("null")
            providers = Providers("null")
            choice_num = ChoiceNum(0)
            start_year = StartYear(0)
            end_year = EndYear(9999)
            ques_num = QuesNum(2)
            user_items = User(user_id, content_type, genre, providers, choice_num, start_year, end_year, ques_num)
            self.firebase.create_document(user_items)
            # 返却する言葉を実装
            line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(text='動画の視聴方法を選んでください',
                                quick_reply=QuickReply(items=[
                                    QuickReplyButton(
                                        action=MessageAction(label="Amazon prime video",
                                                             text="Amazon prime video")),
                                    QuickReplyButton(action=MessageAction(label="Netflix", text="Netflix")),
                                    QuickReplyButton(action=MessageAction(label="hulu", text="hulu")),
                                    QuickReplyButton(action=MessageAction(label="U-NEXT", text="U-NEXT")),
                                    QuickReplyButton(action=MessageAction(label="Disney+", text="Disney+")),
                                ])
                                )
            )
        except:
            line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(text='実行に失敗しました、お手数ですがもう一度お願いします',
                                quick_reply=QuickReply(items=[
                                    QuickReplyButton(action=MessageAction(label="初めからやり直す", text="初めからやり直す")),
                                ])
                                )
            )

ってな感じで

  1. 受け取った瞬間、初めての回答であれば,DBを作成
  2. 2つめ以上の回答であればDBを更新
  3. 全ての質問を回答し終えたら映画を探す処理を開始
    といった処理を書いています。

ちなみに今どの質問なのか?ということを判断するには

class MainFuncImpl(MainFunc):
    def __init__(self):
        self.firebase = UserRepositoryImpl()
    def handle_main_func(self, event: str, text: str, user_id: str, api_token: str, line_bot_api: LineBotApi):
        # firebaseを扱うためにインスタンス化を行う
        try:
            ques_num: int = self.firebase.read_document_question_num("ques_id", user_id)
        except:
            ques_num = 1

        if text == "探す" or text == "初めからやり直す":
            self.first_question_func(event, user_id, line_bot_api)
        elif ques_num == 2:
            self.second_question_func(event, user_id, line_bot_api)
        elif ques_num == 3:
            self.third_question_func(event, user_id, line_bot_api)
        elif ques_num == 4:
            self.fourth_question_func(event, user_id, line_bot_api)
        elif ques_num == 5:
            self.fifth_question_func(event, user_id, line_bot_api)
        elif ques_num == 6:
            self.sixth_question_func(event, user_id, line_bot_api)
        elif ques_num == 7:
            self.final_question_func(event, user_id, api_token, line_bot_api)
        else:
            self.except_func(event, user_id, line_bot_api)

以上のように有限オートマトンという技術を使っています。

アーキテクチャ

正直本を2冊読んで実装した程度なのであまり自信がありません。
有識者の方で理解がある方がいましたらぜひアドバイスをいただきたいです...

まずはこちらの図を見てください
image.png
クリーンアーキテクチャをご存じの方ならよく見たことがある図だと思います。

この図の解説に入る前にドメイン駆動設計についてお話しさせてください

そもそもクリーンアーキテクチャはドメイン駆動設計の考えを反映した具体的な設計モデルであるからです

じゃあドメイン駆動設計ってなんじゃい?

試しに今熱いChatGPTくんに聞いてみました

image.png

いやー、わかりやす!!感動した!!

...とはなりませんよね?は?って感じしませんか?

簡単にいうとアプリケーション作成においてしっかりとビジネスにおける理解などをコードに反映していくための有効的な手法ってことです。

例えば今回であればlinebotに必要な情報は

UserのId
選びたい媒体
ジャンル
開始の年代
終わりの年代
現在の質問番号

と言ったものとなっており、これらのドメインモデルを、pythonやfirebaseを用いて、APIを叩いたり、DBに格納したり、Line側に返したりと言ったことをします

これはどのアプリケーションでも同じで、□□の技術を用いて〇〇を使って▲をする!と言ったようなことを行っていくわけです。

コードを書く前に今回はどう言ったものを使ってどう言ったことをしていくのかということをしっかりと理解する必要があるってことです。

曖昧だなぁ...、って思いましたか?実はそれが重要なんです!!

実はドメイン駆動設計(DDD)のきもは外側にいくにつれて設計が具体的になっていくことなんです!

それではクリーンアーキテクチャ

では先ほどのクリーンアーキテクチャの図に戻ってください

外側にいくにつれて設計が具体的になっていくということを思い出してよくみて見てくださいね!

と、その前に皆さんはこんな経験ありませんか?

Apexをプレイするとき、プレステをテレビに繋いでやっていたんだけど、最近ゲーミングモニターを買ったからそっちに繋ぎ直そ!!

でもこれって別にプレステからしたら別にどっちでもいいわけですよね?プレステで設定をいじって、Apex側でテレビじゃなくてモニター対応にしないとバグが出るとかではないですよね?

単に、繋ぎ直せばいいだけなわけですよ

実はこれが一番重要な概念なのです!

クリーンアーキテクチャ(ドメイン駆動設計)も同じで技術選定をどうするかってことは知ったことではないんですよ

例えば今回であればDBはfirebaseを使っていますが、それって別にコアな部分からしたら別にどうでもいいってことなんですね、だからDBの具体的な処理は一番外側に書くわけです。

こういう設計を心がけることで、DBも途中で変えたいってなった時にモニターのように外側だけを取り外しすればいいだけってことになるんですね〜、いやぁ素晴らしい!

つまり?

今までの話をまとめると

  • 外側にいくほど具体的なコードを書く必要がある

    • これを書籍とかでは依存関係などと言ったりします
  • 層ごとに役割をしっかりと分ける必要がある

    • これを書籍では関心の分離などと言ったりします

以上を踏まえてそれぞれの階層の説明

.
├── Dockerfile
├── README.md
├── app.py
├── .env
├── .gitignore
├── domain  #クリーンアーキテクチャのentities層です
│   ├── entity 
│   │   └── user_model.py
│   ├── repository
│   │   └── user_repository_interface.py
│   └── value_object
│       ├── choice_num.py
│       ├── content_type.py
│       ├── end_year.py
│       ├── genre.py
│       ├── providers.py
│       ├── ques_id.py
│       └── start_year.py
├── firebase-admin.json
├── infrastructure #クリーンアーキテクチャのgateways層です
│   └── firebase
│       ├── database_connect.py
│       └── repository
│           └── user_repository.py
├── controllers #クリーンアーキテクチャのcontrollers層です
│   ├── api
│   │   ├── get_img.py
│   │   └── movie_search.py
│   └── services
│       └── main.py
├── requirements.txt
└── usecase  #クリーンアーキテクチャのusecase層です
    ├── api
    │   ├── get_img_interface.py
    │   └── movie_search_interface.py
    ├── dto
    │   └── user_items_dto.py
    ├── response
    │   ├── res_1.py
    │   ├── res_2.py
    │   ├── res_3.py
    │   ├── res_4.py
    │   └── res_5.py
    └── services
        └── main_interface.py

domain層

この層は一番中心であり主にこのアプリケーションが扱うものはどんなものなのか?という処理が書いてあります。

またクリーンアーキテクチャの図のentities層がこれにあたります

なんで名前が違うねんと思った方がいるかもしれませんが、ドメイン駆動設計とクリーンアーキテクチャで少し名前に違いがあったりするんですよね...、僕はディレクトリの命名はドメイン駆動設計から取ってきてます

少し話がずれましたが、ここでは大きく3つに分けられます

  1. entity
  2. repository
  3. value_object

repository

一番簡単なのでここからお話しいたします。

先ほど、DBを扱う上でDBの種類(NoSQLなのかRDBなのか)と言った話は具体的だから外側に書くぞいという話をしました。

しかしどのDBを使うかは決まっていなくてもどう言った処理の種類があるのか?(どういう実装にするかもまだよくわかってない)と言った話は割と抽象的は話なわけです

それをここでは実装します。

また今回はinterfaceを用いていますが、ダックタイピングでもよかったなぁ...って思っています

class UserRepository(ABC):
    @abstractmethod
    def format_json(self, user_items: User) -> Dict:
        pass

    @abstractmethod
    def create_document(self, user_items: User):
        pass

    @abstractmethod
    def read_document_question_num(self,colum_name:str, user_id: str) -> int:
        pass

    @abstractmethod
    def read_document(self, user_id: str) -> Dict:
        pass

    @abstractmethod
    def update_document(self, colum_name: list, value: list, user_id: str):
        pass

    @abstractmethod
    def delete_document(self, user_id: str):
        pass

あくまで何をやるかは具体的なのでここには書かないけど、外側にいくに連れてこのclassを継承してメソッドを埋めていく感じですね

エンティティとvalueオブジェクト

例えばあなたが何かしらのサイトに登録した時

id: 12425235262
名前: 田中
 性別: 男
出身地: 愛知

だったとした時、あなたが唯一無二であることを証明するために必要な属性はどれになるでしょうか?

当たり前ですが、idですよね

田中だったら別の田中さんと分けれなくなってしまうし、それ以外でも同じ人がいないとは限らないわけですから

この時,idを含んだその人をその人であると知らしめるための情報を含んだものをentityといい、その人が持っている属性をvalueオブジェクトと言います

つまり、valueオブジェクトは被ってもいいけどentityは被ったらまずいんですよ
だからentityはDBに保存されるときに使われるんですね〜

ちなみにvalueオブジェクトはclassで書いていくわけですが初期化するときに条件を入れることでエラーを摘出します

僕のコードであれば

from typing import Type


class Providers:
    def __init__(self, provider_name: str):
        providers_list = [
            "amp", # アマプラ
            "nfx", # ネトフリ
            "hlu", # Hulu
            "unx", # U-NEXT
            "dnp", # ディズニープラス
            "null"

        ]
        if provider_name not in providers_list:
            raise ValueError("provider_name is not collect")
        self.provider_name = provider_name

こんな感じで描いてます

これを書くと何がええねん?って話ですが、、エラーを吐いてくれるってのはもちろんこれを見るとコンテンツの種類が5種類だってすぐにわかりませんか?

classに振る舞いを書くことでどう言った属性を持つvalueオブジェクトなのかってのがわかりやすくなっているんですね〜

ちなみにentityは

import dataclasses

from domain.value_object.choice_num import ChoiceNum
from domain.value_object.content_type import ContentType
from domain.value_object.end_year import EndYear
from domain.value_object.genre import Genre
from domain.value_object.providers import Providers
from domain.value_object.ques_id import QuesNum
from domain.value_object.start_year import StartYear


# ユーザーが持つ値のモデルをvalueobjectとして保持
@dataclasses.dataclass
class User:
    id: str
    content_type: ContentType
    genre: Genre
    providers: Providers
    choice_num: ChoiceNum
    start_year: StartYear
    end_year: EndYear
    ques_num: QuesNum

こんな感じでvalueオブジェクトをユーザーは保持するよーってことをコードを見ればわかる作りになっているわけです。

これを見ればユーザーはどう言った種類の属性を持つのかすぐにわかりますよね?これがこのdomain層でやりたかったことです。

usecase層

ここはクリーンアーキテクチャのusecase層です

ここは主にメインロジックを書く層になります

今回は処理が多いので,domain層にinterfaceを作っています(ぶっちゃけなくてもいい)

例えばAPIを呼ぶ処理なんかはどう言ったメソッドはあるのかという定義だけした後は別のところで実装します。

class MainFunc(ABC):
    @abstractmethod
    def first_question_func(self, event: str, user_id: str, line_bot_api: LineBotApi):
        pass

    @abstractmethod
    def second_question_func(self, event: str, user_id: str, line_bot_api: LineBotApi):
        pass

    @abstractmethod
    def third_question_func(self, event: str, user_id: str, line_bot_api: LineBotApi):
        pass

    @abstractmethod
    def fourth_question_func(self, event: str, user_id: str, line_bot_api: LineBotApi):
        pass

    @abstractmethod
    def fifth_question_func(self, event: str, user_id: str, line_bot_api: LineBotApi):
        pass

    @abstractmethod
    def sixth_question_func(self, event: str, user_id: str, line_bot_api: LineBotApi):
        pass

    @abstractmethod
    def final_question_func(self, event: str, user_id: str, api_token: str, line_bot_api: LineBotApi):
        pass

    @abstractmethod
    def except_func(self, event: str, user_id: str, line_bot_api: LineBotApi):
        pass

    @abstractmethod
    def handle_main_func(self, event: str, text: str, user_id: str, api_token: str, line_bot_api: LineBotApi):
        pass

まとめると

UseCase層には、domain層を用いて具体的な処理を書く層になります

infrastructure,controllers層

やっとここまできましたね、ここは一番外側の処理になるのでだいぶフレームワークやライブラリ依存のコードを書くことになります

例えばDBであればここで初めてfirebaseの処理が書いてあることがわかると思います。

どう言ったことをやるかは1つ目の層に書いて、何を使って具体的な処理は何をやるのかということをここに書いてあるわけです。

class UserRepositoryImpl(UserRepository,FirebaseConnect):
    def __init__(self):
        FirebaseConnect.connect(self)
    def format_json(self, user_items: User):
        # firebaseに保存するときにjson形式である必要があるため,そのための処理を実装
        return {
            'id': user_items.id,
            'content_type': user_items.content_type.name,
            "genre": user_items.genre.genre_name,
            "providers": user_items.providers.provider_name,
            "choice_num": user_items.choice_num.choice_num,
            "start_year": user_items.start_year.start_year,
            "end_year": user_items.end_year.end_year,
            "ques_id": user_items.ques_num.ques_num,
        }

    def create_document(self, user_items: User):
        # すでに存在しているテーブルの場合は削除
        db = firestore.client()
        db.collection('user_info').document(user_items.id).delete()
        doc = db.collection('user_info').document(user_items.id)

        # jsonに変換した後DBに保存
        set_user_json = self.format_json(user_items)
        doc.set(set_user_json)

    def read_document_question_num(self,colum_name:str, user_id: str) -> int:
        db = firestore.client()
        doc = db.collection('user_info').document(user_id)
        return doc.get().to_dict()[colum_name]

    def read_document(self,  user_id: str) -> Dict:
        db = firestore.client()
        doc = db.collection('user_info').document(user_id)
        return doc.get().to_dict()

    def update_document(self, colum_name: list, value: list, user_id: str):
        db = firestore.client()
        doc = db.collection('user_info').document(user_id)
        for i in range(len(colum_name)):
            field_name = colum_name[i]
            value_name = value[i]
            doc.update({field_name: value_name})

    def delete_document(self,user_id: str):
        db = firestore.client()
        doc = db.collection('user_info').document(user_id)
        doc.delete()

このように中心の方で書いたinterfaceを実装していくのがここでの具体的な流れとなります。

domain層にineterfaceを書いて外側で実装,usecaseから呼び出す時はdomain層のinterfaceを呼び出します

そうすることで依存方向の逆転を解消しています

最後に(ここまで読んでいただきありがとうございます)

ここまで読んでいただきありがとうございます。

最後にいくにつれて少し説明が雑になってしまい申し訳ございません。

正直、ドメイン駆動設計は設計の概念のため割とディレクトリ構造も人によってバラバラだったりします。
なので何が正解ってのはないのかもしれません。

ただ、大事なのは依存関係を守ること(外側の層は内側の層に依存する、あくまで内側は外側を知る必要はない)だと思っています。

正直2週間で本や記事に目を通して実装したというレベルなのであっているか分からない部分も多くあるため、もしよろしければたくさん指摘してくれると嬉しいです。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1