見出し画像

SwitchBotアプリが使いにくいので自作してみる~ソフトウェア設計~

前回記事

SwitchBot製品をコントロールするアプリを自作しようとしています

前回は、APIにて取得したデバイス一覧を表形式で表示するところを簡単に作りました。

突貫工事で作ったプログラムなので設計など大して考えられていません。
今回は、デバイス一覧を表示する機能はそのままで、今後の拡張性を考慮してソフトウェア設計を見直したいと思います。

だいぶ、プログラマー寄りの記事になります。
※私自身ソフトウェア設計初心者かつ趣味開発の一環なので突き詰め切れていないところあります。ご了承ください

なお、ソースコードは以下にPushしています

MVCパターン

前回の実装では、データ取得とViewへのデータ受け渡しを同一のクラス(関数)で行っていました。

まずこの辺りをMVCパターンを参考に役割を分けたいと思います。

MVCパターンとは、ソフトウェアアーキテクチャのパターンの1つで、
アプリケーションを

  • Model

  • View

  • Controller

と大きく3つに分けて設計を行います

Model

DBとのやり取りや、ビジネスロジックを担当する部分
いわゆるバックエンドの部分

View

アプリの見た目に関する部分
いわゆるフロントエンドの部分

Controller

ModelとViewの架け橋的存在。
ユーザー入力を受けて適切なロジック(Model)をコールし、その結果をViewに渡すなど。

前回の実装ではViewの切り出しはできていたので、今回はModelとContorollerの分割。Modelの設計をメインに作業します

Controllerの実装

今回はFlaskを用いてアプリを構築しています。
アプリのエントリポイントとなるapp.pyをController的役割にしました。

from flask import Flask, render_template
from ApplicationService.device_app_service import DeviceAppService
from ApplicationService.dto_device import DeviceList
from Infra.device_repository import DeviceRepository

app = Flask(__name__)


@app.route("/")
def index():
    device_app_service = DeviceAppService(DeviceRepository())
    device_list: DeviceList = device_app_service.get_all()
    return render_template(
        "index.html", columns=device_list.columns, devices=device_list.devices
    )


if __name__ == "__main__":
    app.run(debug=True)

「デバイス一覧の取得」ロジックをDeviceAppServiceに切り出しました。
トップページへのアクセスで、get_allをコールし結果を取得、
Viewの要求するデータに一致するようにそれを渡す。
これだけの役割に徹します。

トップページへのアクセスの度にインスタンスを作るのは微妙かもしれませんね。依存関係の解決もDIコンテナを使った方がよさそうですが、今回は構造の見直しを中心に進めます

Modelの実装 - DDDリスペクト

MVCパターンと言われると三者対等な気がしますが、実装量は圧倒的にModelが多くなります。
そのため、Model内でもまたアーキテクチャを考える必要があるでしょう。

ここではドメイン駆動設計(DDD)をリスペクトして作業していきます。
※私はあくまでDDD勉強中の身なので、DDDとは言い切らず、DDDリスペクトと言わせてください。

今回念頭に置いているのは「デバイス一覧の取得」というユースケースです。

以下のようなイメージでこれを実現させます

アプリケーションサービス

デバイスに対するユースケースはDeviceAppServiceに実装します。

from Domain.device_repository import IDeviceRepository
from Domain.device import Device
from .dto_device import DeviceList
from typing import Tuple


class DeviceAppService:
    def __init__(self, device_repository: IDeviceRepository):
        self.device_repository = device_repository

    def get_all(self):
        devices: Tuple[Device] = self.device_repository.get_all()

        dto_devices = []
        for device in devices:
            dto_devices.append((device.id, device.name, device.type))

        return DeviceList(("id", "name", "type"), dto_devices)

DeviceAppServiceは、リポジトリに対してデータを引っ張ってくるよう要求します。
リポジトリからはDeviceクラスのタプルを貰うようにします。

それを、Controller層へ渡すためのDTO(Data Transfer Object)に変換し渡します。

DTOは、他レイヤーにドメインモデルをそのまま流してしまうことによる副作用等を回避するため、ドメインモデルから必要なデータのみ抜き出したオブジェクトです。
今回はデバイス一覧を以下のDTOに詰め替えてControllerに渡しています

from dataclasses import dataclass
from typing import Tuple


@dataclass(frozen=True)
class DeviceList:
    columns: Tuple[str, ...]
    devices: Tuple[Tuple[str, ...]]

リポジトリ

DB操作はかなり具体的な実装を必要とします。
具体的な実装は変更の余地が大きく、そういったに影響を受けやすい設計にするとメンテ量が増大してしまいます。

また、本番とテストとで利用するDBを切り替えたい場合が多々あります。

そのため、抽象クラスとしてリポジトリを定義します

from abc import ABC, abstractmethod
from Domain.device import Device
from typing import Tuple


class IDeviceRepository(ABC):
    @abstractmethod
    def get_all(self) -> Tuple[Device]:
        pass

現状必要なのはデバイス一覧の取得のみです。

リポジトリの利用側(今回はDeviceAppService)を抽象クラスに依存させれば、利用側は実装側の変更と無関係でいられます。

今回は本番用のSQLiteの利用を実現するDeviceRepositoryと、テスト用のInMemoryDeviceRepositoryを実装しました。

class DeviceRepository(IDeviceRepository):
    def __init__(self):
        self_dir = os.path.dirname(os.path.abspath(__file__))
        self.db_path = os.path.join(self_dir, "devices.db")

    def get_all(self) -> Tuple[Device]:
        connection = sqlite3.connect(self.db_path)
        cursor = connection.cursor()

        cursor.execute("SELECT id,name,type FROM devices")
        result = cursor.fetchall()
        connection.close()

        devices = []
        for r in result:
            device = Device(r[0], r[1], r[2])
            devices.append(device)

        return tuple(devices)


class InMemoryRepository(IDeviceRepository):
    def __init__(self):
        self.conn = sqlite3.connect(":memory:")
        cursor = self.conn.cursor()
        cursor.execute(
            """
                CREATE TABLE devices (
                    id TEXT PRIMARY KEY,
                    name TEXT NOT NULL,
                    type TEXT NOT NULL,
                    enable_cloud_service BOOLEAN NOT NULL,
                    hub_device_id TEXT NOT NULL
                )
            """
        )
        self.conn.commit()

    def add(self, id, name, type, enable_cloud=True, hub_device_id="0000"):
        cursor = self.conn.cursor()
        cursor.execute(
            """
            INSERT OR IGNORE INTO devices (id, name, type, enable_cloud_service, hub_device_id)
            VALUES (?, ?, ?, ?, ?)
            """,
            (
                id,
                name,
                type,
                enable_cloud,
                hub_device_id,
            ),
        )
        self.conn.commit()

    def get_all(self):
        cursor = self.conn.cursor()

        cursor.execute("SELECT id,name,type FROM devices")
        result = cursor.fetchall()

        devices = []
        for r in result:
            device = Device(r[0], r[1], r[2])
            devices.append(device)

        return tuple(devices)

sqlite3は、インメモリのDB構築をサポートしてくれています。

アプリケーションサービスのテスト

面倒ですがテストも実装しました。
ガチ勢に怒られそうですが、(趣味開発なので)一旦簡単に

import unittest
from .device_app_service import DeviceAppService
from Infra.device_repository import InMemoryRepository


class TestDeviceAppService(unittest.TestCase):
    def setUp(self):
        self.db = InMemoryRepository()
        self.device_app_service = DeviceAppService(self.db)

    def test_get_all(self):
        self.db.add("1", "ColorLight", "Color Bulb")
        device_list = self.device_app_service.get_all()
        self.assertEqual(len(device_list.devices), 1)


if __name__ == "__main__":
    unittest.main()

まとめ

今回は一旦ここまでとします。
内容をまとめると

  • MVCパターンを参考に責務分割

  • Model部分はドメイン駆動設計を参考に構築

となります。

クラス分割を行ったことでテストしやすい構造になりました。
次回は、今回の設計に則り機能追加を行いたいと思います


いいなと思ったら応援しよう!