Site Overlay

簡単なWebサービスをPythonのみで作ってみた3

こんにちは、本日もpythonでWebサービスを作ってみます。

簡単なWebサービスをPythonのみで作ってみた1では、ストラテジーパターンを適用して、画面を作成しましたが、今回は内部状態を持たせ、状態に応じてそれぞれのメニューを表示する、といったことをジコマンで勝手にやっていきます。

サマリ

さてまずは今回やったことのまとめです。

  • これまでの画面に1画面追加する
  • 追加した画面にボタンを4つ(初期、新設、取得、削除)を生成する。
  • 初期、新設、取得、削除のいずれかを押下したとき、内部状態を変化させる。
  • それぞれのボタンの応じた画面を表示する。

画面イメージ

完成した画面は以下の通りです。

初期状態の画面

「新規」ボタン押下後の画面

「取得」ボタン押下後の画面

「削除」ボタン押下後の画面

もちろんそれぞれのボタン押下後の画面での処理も実施できます。例えば「取得」ボタン押下後の画面で「シーケンス番号」指定して「照会」ボタンを押下すると以下のように表示されます。

今回、このようなボタンによる画面の切替をステートパターンを用いて実装しました。

実装コード

さてここからコードの説明に入ります。アプリは簡単なWebサービスをPythonのみで作ってみた2までのものを前提に、追加開発しています。
※もし全量が欲しい場合は連絡いただけば、githubに招待いたします。

・view_strategy.py

前回の繰り返しになりますが、新しい画面を追加するには、

  • 「view_strategy.py」に1つクラスを新設
  • 「menu.properties」にその定義を追加

で対応可能であり、既存のコードに一切手を入れずに機能追加ができます。

from abc import ABCMeta, abstractmethod
import json
import requests
import streamlit as st
import pandas as pd
from states import *



class IView(metaclass=ABCMeta):
    _url: str = "http://localhost:8000/api/v2/users/"

    @abstractmethod
    def show_screen(self) -> None:
        pass
・
・
<中略>
・
・
@st.cache_resource
class ViewCustom(IView):

    __state: IState = None
    __state_manage = StateManage()

    disabled_msg = "未許可遷移のため、遷移できなかったでござるよ"

    def show_screen(self) -> None:
        if not self.__state:
            self.__state = InitialState.get_instance()
        
        col0, col1, col2, col3 = st.columns(4)
        if col0.button("初期"):
            if self.__state_manage.is_transitionable(self.__state.get_classname(), States.InitialState):
                self.__state = InitialState.get_instance()
            else:
                st.write(self.disabled_msg)
        if col1.button("新規"):
            if self.__state_manage.is_transitionable(self.__state.get_classname(), States.NewAddState):
                self.__state = NewAddState.get_instance()
            else:
                st.write(self.disabled_msg)
        if col2.button("取得"):
            if self.__state_manage.is_transitionable(self.__state.get_classname(), States.GetDataState):
                self.__state = GetDataState.get_instance()
            else:
                st.write(self.disabled_msg)
        if col3.button("削除"):
            if self.__state_manage.is_transitionable(self.__state.get_classname(), States.DeleteState):
                self.__state = DeleteState.get_instance()
            else:
                st.write(self.disabled_msg)
        
        self.__state.show()
{  
    "home": "ViewHome", 
    "inquire": "ViewUser",
    "inquireAll": "ViewAllUsers",
    "register": "ViewRegister",
    "update": "ViewUpdate",
    "deleteAll": "ViewDeleteAll",
    "custom": "ViewCustom"
}

さて少しソースについてみていきます。

今回「ViewCustom」クラスを新設してます。このクラスは他のクラスと違って、デコレータ「@st.cache_resource」を追加しています。これは通常Streamlitはボタンをform_submit_buttonを押下するとインスタンスの再読み込みが走ります。その影響で内部の状態が常に初期化されてしまうのですが、「@st.cache_resource」をつけることで、リソースをキャッシュできるようになります。

ステートパターンにおける「Context」クラスが「ViewCustom」に相当します。

「__state」が内部状態を表すインスタンス変数です。外から隠蔽するためprivateにしてます。
「__state_manage」が状態遷移を管理するクラスのインスタンス変数です。これは後述します。

「show_screen」メソッドで、初期、新規、、、のボタンを配置と押下時の挙動を定義し、それ以降の各状態の画面ロジックは内部状態「__state」のshowメソッドに委譲しています

states.py

次に状態に関する機能はstates.pyに入れてます。コードは以下の通りです。

from abc import abstractmethod
from enum import Enum
import json
from typing import Any
import requests
import streamlit as st


# 状態の基底クラス
class IState:
    _instance = None
    kbn: str = '基底'
    _url: str = "http://localhost:8000/api/v2/users/"
    
    def __init__(self):
        raise NotImplementedError('Cannot Generate Instance By Constructor')
   
    @classmethod
    def get_instance(cls):
        if not cls._instance:
            cls._instance = cls.__new__(cls)
        return cls._instance
    
    @classmethod
    def get_classname(cls):
        return States[cls.__name__]

    @abstractmethod
    def show(self) -> None:
        pass

# Conscreteクラス 初期状態
class InitialState(IState):
    kbn: str = "初期状態ですぞ〜"

    def show(self) -> None:
        st.header(self.kbn)        
        return

# Conscreteクラス 新規登録状態
class NewAddState(IState):
    kbn: str = "データをぶっこむですぞ"

    def show(self) -> None:
        st.header(self.kbn)
        with st.form(key="custom"):
            seq_nbr: str = st.text_input("シーケンス番号")
            first_name: str = st.text_input("First Name")
            last_name: str = st.text_input("Last Name")
            gender: str = st.radio(
                label="性別を選択せよ",
                options=("female", "male"),
                index=0,
                horizontal=True,
            )
            roles: str = st.radio(label="ロールを選択せよ", options=("admin", "user"))

            payload = {
                "sequence_nbr": seq_nbr,
                "first_name": first_name,
                "last_name": last_name,
                "gender": gender,
                "roles": roles,
            }
            submit_bottun: bool = st.form_submit_button(label="登録")
            if submit_bottun:
                res = requests.post(url=self._url, json=json.dumps(payload))
                if res.status_code == 200:
                    st.success("登録完了")
                st.markdown(res.text)

# Conscreteクラス 取得状態
class GetDataState(IState):
    kbn: str = "データをゲットするですぞ"

    def show(self) -> None:
        st.header(self.kbn)
        with st.form(key="custom"):
            seq_nbr: str = st.text_input("シーケンス番号", key="seq")
            submit_bottun = st.form_submit_button(label="照会")
            if submit_bottun:
                res = requests.get(self._url + seq_nbr)
                decorded_text = res.content.decode("utf-8")
                list_data = json.loads(decorded_text)
                if not isinstance(list_data, list):
                    list_data = [list_data]
                st.table(list_data)

# Conscreteクラス 削除状態
class DeleteState(IState):
    kbn: str = "データを削除しまするぞ"

    def show(self) -> None:
        st.header(self.kbn)
        with st.form(key="custom"):
            submit_bottun: bool = st.form_submit_button(label="削除")
            if submit_bottun:
                res = requests.delete(self._url)
                if res.status_code == 200:
                    st.success("削除完了")
                st.markdown(res.text)
                

# 状態の列挙クラス
class States(Enum):
    InitialState = "InitialState"
    NewAddState  = "NewAddState"
    GetDataState = "GetDataState"
    DeleteState  = "DeleteState"


# 状態の管理クラス
class StateManage:
    # 可能な遷移を予め定義
    transition_enabled: dict = {}
    transition_enabled[States.InitialState]  =  {States.NewAddState, States.GetDataState, States.DeleteState}
    transition_enabled[States.NewAddState]   =  {States.GetDataState, States.DeleteState}
    transition_enabled[States.GetDataState]  =  {States.InitialState, States.NewAddState, States.DeleteState}
    transition_enabled[States.DeleteState]   =  {States.GetDataState}

    # 可能な遷移か否か判定
    def is_transitionable(self, from_state: States, to_state: States) -> bool:
        is_enabled = False
        if from_state in self.transition_enabled:
            enabled_to_state_set: dict = self.transition_enabled[from_state]
            if to_state in enabled_to_state_set:
               is_enabled = True
        return is_enabled
    

詳細はコードの通りなので、ポイントをいくつか述べておきます。

まず「IState」クラスを基底クラスにして、これを継承して、ConcreteStateクラスを実装しています。また状態はいくつもインスタンスを生成する必要もないので、シングルトンにしてます。

ただし、マルチスレッドの場合はシングルトンの保証が完全ではないので、注意してください。スレッドAがインスタンス生成中にスレッドBがインスタンス生成を開始すると、2つインスタンスが作られてしまいます。

とりうる状態はなるべく仕掛けで縛るようにするためEnumクラスで状態をまとめています。さらに可能な状態遷移をdict型(他の言語ならmap型)でまとめてしまってます。これによって、今後状態を新たに新設したとしても、既存への影響をなしにできます

遷移可能かどうかは「is_transitionable」での判定を提供することで、Context側(VeiwCustomクラス側)で実施するようにさせています。

最後に

ステートパターンはストラテジーパターン同様、リファクタリングのツールになったりします。身につけてみるとかなりすっきりしたコードになったります。

最後まで読んでいただきありがとうございます。
質問等はコメント欄かお問合せにてよろしくおねがいいたします。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です