Chapter 03

設計方針

alivelimb
alivelimb
2022.05.31に更新

本章ではこれまでの章の内容を踏まえて、設計方針を紹介します。設計方針は「積極的に TypeHint を活用すること」「責務を明確に分け、適切な粒度でオブジェクトを分割すること」「バックエンドの存在を前提にすること」の 3 つです。

TypeHint を活用する

TypeHint とは以下のように変数や戻り値の型をヒントとして記述することです。

def add(a: int, b: int) -> int:
    return a + b

これはあくまでヒントなので実行時にその型を強制するわけではありません。つまり実行時にadd("1", "2")と書いても、文字列結合されて 12 になり期待する挙動ではありませんが、異常終了はするわけでもありません。

では何のためにかくのか。この利点は主に 2 つあります。

  • 開発者がコードを理解するのを助けてくれる
  • 静的解析と組み合わせることで、実行前のバグ除去を助けてくれる

VSCode などの IDE では TypeHint を書くことで、予測変換が効くようになったり、文法的におかしい部分は警告を表示してくれるようになります。TypeHint と VSCode を活用したデモは以下の通りです。

VSCodeデモ

実行前にバグを潰し込めるため、品質の良いコードに繋がると考えています。そのため以降のソースコードは全て TypeHint を活用します。また、VSCode でデモのような Python 開発環境を構築する方法については、記事にしているので適宜参照してください。

Python3.8 と 3.9 で TypeHint の書き方が異なる

TypeHint はまだまだ発展途上と考えています。その中でも Python3.8 と 3.9 では大きく変わっている部分があるので注意が必要です。主な変更点は以下の 2 点です。

Python3.8 Python3.9
1 typing.List[]など list[]など
2 typing.Callable[]など collections.abc.Callable[]など

1 つ目は組み込み型のlist, dict, tupleなどの書き方が変更してされています。

from typing import List

names38: List[str] = ["Taro", "Jiro", "Saburo"] # Python3.8
names39: list[str] = ["Taro", "Jiro", "Saburo"] # Python3.9

2 つ目はcollectionsパッケージのクラスが TypeHint に対応したため、typingを使うのは非推奨になっています。

import typing
from collections.abc import Callable

callback38: typing.Callable[[int], int] = lambda x: x**2
callback39: Callable[[int], int] = lambda x: x**2

Python3.9 を使っている場合は、Python3.8 の書き方をしても問題はありませんが、逆は出来ません。本書では Python3.9 で開発しており、Python3.9 の書き方に準拠する方針としています。

責務を明確にしてオブジェクトを分割する

TypeHint は良いコードへの第一歩だと思っていますが、設計の良し悪しは TypeHint では流石に判断できません。設計が良くなければバグの原因になったり、開発効率が落ちたりします。良い設計の 1 つの観点としてオブジェクトが適切な粒度で分割されていることが挙げられると思います。

Model, Page, Service, (+SessionManager, Application)に分割する

Web アプリケーションのフレームワークとして MVC(Model-View-Controller)などが有名かと思います。私の認識では、MVC はどちらかというとバックエンドのフレームワーク、フロントエンドであれば AtomicDesign などのコンポーネント単位で分割することが多いように思います。(参考)

コンポーネントは Streamlit が用意してくれているので、自分達で作る必要ありません。また Streamlit のコンポーネントを組み合わせたクラスを定義することも可能かと思いますが、本書では行いません。

今回は私の独自の分け方になってしまいますが、以下の責務で分割したいと思います。

No 名前 責務
1 Model エンティティを定義する
2 Page Streamlit にページ生成を依頼する
3 Service フロント-バック間でデータを送受信する
4 SessionManager Streamlit のセッションとデータをやり取りする
5 Application ページの表示切り替えを行う

2 点補足しておきます。

  1. Model はデータを保持するだけ(いわゆるデータクラス)で、ビジネスロジックは持ちません。ビジネスロジックはバックエンド側で持つことを想定しています。
  2. 実際のソースコードと名称が異なる部分があります。SessionManager はStreamlitSessionManager, Application はMultiPageAppになります。

再掲になりますが、YaEC の全体像は以下の通りです。

YaECシステム全体像

イミュータブルを活用する

Model で定義しているデータクラスは全てイミュータブル(変更不可)にしています。Python にはdataclassが用意されており、frozenを用いることでイミュータブルなデータクラスを用意できます。

# 狼に食べられないようにイミュータブルにしよう!
# ※このコードを実行すると例外が発生します
from dataclasses import dataclass


@dataclass
class Mutable:
    is_broken: bool = False


@dataclass(frozen=True)
class Immutable:
    is_broken: bool = False


taro_house = Mutable()
jiro_house = Mutable()
maburo_house = Immutable()

taro_house.is_broken = True
jiro_house.is_broken = True
maburo_house.is_broken = True  # 例外発生: dataclasses.FrozenInstanceError: cannot assign to field 'is_broken'

ミノ駆動さんの良いコード/悪いコードで学ぶ設計入門ではイミュータブルについてはもちろん、よい設計のための情報が詰まっているためおすすめです。

abc.ABC か typing.Protocol か

オブジェクト指向であれば SOLID 原則が有名で、Python の開発においても役に立つ原則だと思っています。既に紹介した Model, Page などのオブジェクト分割は単一責任の原則に従っています。SOLID 原則については様々な記事・書籍等で紹介されていますが、私が分かりやすいと思ったのは Robert C.Martin のClean Architecture です。

一方で Jave などで言う Intreface は Python にはありません。abc.ABCで代替することはできると思いますが、本書ではダックタイピングで書くため、typing.Protocolを用いることにします。

from pathlib import Path
from typing import Protocol

class IRepository(Protocol):
    def get(self) -> list[str]:
        pass

class FileRepository(IRepository):
    def get(self) -> list[str]:
        return Path("members.txt").read_text().split(",")

class MemoryRepository(IRepository):
    def get(self) -> list[str]:
        return ["Taro", "Jiro", "Saburo"]

上記の例ではdef get(self) -> list[str]のシグネチャ(関数名、引数、戻り値のセット)を持っていれば IRepository とみなされます。

バックエンドの存在を前提にする

「はじめに」で述べた通り、YaEC ではフロントエンドとバックエンドを明示的に分ける構成としています。フロント-バック間の通信は HTTP で行うこととするため、Service の IF(インターフェース)定義もこれに沿うように設計にすべきです。

一方で検証の度に毎回バックエンド(API)にアクセスするとテストのサイクルが遅くなったり、開発を進めるに当たって待ちが発生したりするため、好ましくありません。

そこで本書では「バックエンドに HTTP(S)でアクセスする Service」を、「ローカル DB にアクセスする MockService」に置き換えて開発を進めます。モックに置き換えた YaEC のシステム全体像は以下の通りです。

YaECシステム全体像(モック)

ローカル DB にはdataset, TinyDBを利用しています。dataset は RDB に保存するような構造化データの保持に利用し、TinyDB には Redis, MongoDB のような NoSQL に保存するような非構造データの保持に利用します。

dataset

Python で簡単な DB を立てたい時、真っ先に思いつくのはSQLiteでしょう。私も SQLite は重宝していますが、一時的な DB のために CREATE, SELECT などの SQL を書くのやや面倒です。

「それくらい書け」という声が聞こえてきそうですが、そんな怠惰な私にぴったりなのがdatasetです。いくつか特徴がありますが、以下の 2 つの特徴が大きな選定理由です。

  1. SQL を書かずにメソッドでデータ操作が可能
  2. CREATE 文によるスキーマ定義が不要

sqlalchemyに代表される ORM のようにメソッドでデータ操作が可能ですが、クラスを定義する必要はなく辞書形式でやり取りできます。CREATE 文も書く必要がないので、SQLite を直接触るよりも簡単に DB を操作する事が出来ます。

一方で CREATE 文を明示しないということは、主キーや NOT NULL などの制約をつけられないということになります。これは簡単に DB を扱えるようにした裏返しで厳密性を下げているため、あくまで検証用に使うのが良いと思っています。

また、ORM のようにオブジェクト間のリレーション(関係性)も定義することはできません。YaEC では注文テーブルはアイテムテーブルと紐づくため、リレーションが必要になります。ただあくまで MockService であるため、あまり作り込み過ぎないように留めています。

TinyDB

dataset は SQLite, MySQL, PostgreSQL のような RDB の代替として利用されますが、TinyDBは Redis や MongoDB のような NoSQL の代替として利用されます。dataset と同様にメソッドでデータ操作が可能ですが、データ自体は JSON ファイルで保持されます。

「dataset はテーブル定義が必要なく、非構造化データも扱えるため、TinyDB を使う必要ないんじゃないの?」と思った方もいるかもしれません。その通りだと思います。既に述べた通り、TinyDB の紹介をしたかった気持ちが大きいですが、今回はバックエンド側で RDB に保存するものは dataset, NoSQL に保存するものは TinyDB を使うという区分けにしています。

Mimesis

YaEC では野菜の名称や生産地を用意する必要があります。自分で 1 つずつ作っていってもよいのですが、Mimesisを活用してダミーデータを生成します。

Mimesis はダミーデータ生成ライブラリであり、人名、文章、住所など様々な種類のダミーデータを生成することが出来ます。また多言語対応しているのも特徴です。ユースケースとしては今回のようなデモアプリのダミーデータやテスト用のデータの生成などが挙げられます。