作品紹介
- ネットフリックスやアマプラと言った媒体を先に選んで、その後ジャンルや公開年数から映画を絞り込みランダムで5つの作品を返すというようなLinebotになっております。
- ちなみにリンクを踏めば、そのままアプリが起動します。
- 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を使ったことがなかったので、使ってみたくてしょうがなかった笑
- DBはRDBではなくNoSQlを選びました
- ドメイン駆動設計(クリーンアーキテクチャ)
- アーキテクチャは勉強という意味も兼ねてクリーンアーキテクチャを実装しました。
- ここは後ほど解説します。
- Python(Flask)
基本的なロジック
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="初めからやり直す")),
])
)
)
ってな感じで
- 受け取った瞬間、初めての回答であれば,DBを作成
- 2つめ以上の回答であればDBを更新
- 全ての質問を回答し終えたら映画を探す処理を開始
といった処理を書いています。
ちなみに今どの質問なのか?ということを判断するには
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)
以上のように有限オートマトンという技術を使っています。
- 詳しくはここをみてね!
- 簡単に説明すると状態遷移を把握するために番号をDBに格納してありその番号に沿った実装をする処理を書いているってことです
- ここから画像をお借りしました
アーキテクチャ
正直本を2冊読んで実装した程度なのであまり自信がありません。
有識者の方で理解がある方がいましたらぜひアドバイスをいただきたいです...
まずはこちらの図を見てください
クリーンアーキテクチャをご存じの方ならよく見たことがある図だと思います。
この図の解説に入る前にドメイン駆動設計についてお話しさせてください
そもそもクリーンアーキテクチャはドメイン駆動設計の考えを反映した具体的な設計モデルであるからです
じゃあドメイン駆動設計ってなんじゃい?
試しに今熱いChatGPTくんに聞いてみました
いやー、わかりやす!!感動した!!
...とはなりませんよね?は?って感じしませんか?
簡単にいうとアプリケーション作成においてしっかりとビジネスにおける理解などをコードに反映していくための有効的な手法ってことです。
例えば今回であれば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つに分けられます
- entity
- repository
- 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週間で本や記事に目を通して実装したというレベルなのであっているか分からない部分も多くあるため、もしよろしければたくさん指摘してくれると嬉しいです。