🐍

Pythonでの実務でよくありそうな内容のリファクタを検討してみた

2024/01/30に公開

概要

ORMを使ったレコードの操作は便利ですが、特に更新操作時に気をつけないとスパゲッティコードの種になりがちと思ったことと、実際書かれがちなコードを改善していこうと思います。

実装

下記は簡単な例です。
<User(name='Yamada Taro', age='40')>になるかと思います。

from sqlalchemy import Column, Integer, Sequence, String, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker

engine = create_engine("sqlite:///:memory:", echo=False)

Base = declarative_base()


class User(Base):
    __tablename__ = "users"

    def __repr__(self):
        return "<User(name='%s', age='%s')>" % (self.name, self.age)

    id = Column(Integer, Sequence("user_id_seq"), primary_key=True)
    name = Column(String(50))
    age = Column(Integer)


Base.metadata.create_all(engine)

Session = sessionmaker(bind=engine)
session = Session()

new_user = User(name="John Doe", age=30)
session.add(new_user)
session.commit()

user = session.query(User).filter_by(name="John Doe").first()
user.name = "Yamada Taro"
user.age = 40
session.commit()

print(user)

単純なスクリプトであればこれでいいのですが、実際の業務コードでは様々なロジックが行き交います。
当初の要件が変わったものとして、かわいいペットも増やし、実際筆者が遭遇したことのあるコードに近づけたいと思います。

from sqlalchemy import Column, Integer, Sequence, String, create_engine
from sqlalchemy.orm import Session, declarative_base, sessionmaker

engine = create_engine("sqlite:///:memory:", echo=False)

Base = declarative_base()


class User(Base):
    __tablename__ = "users"

    def __repr__(self):
        return "<User(name='%s', age='%s')>" % (self.name, self.age)

    id = Column(Integer, Sequence("user_id_seq"), primary_key=True)
    name = Column(String(50))
    age = Column(Integer)


class Pet(Base):
    __tablename__ = "pets"

    def __repr__(self):
        return "<Pet(name='%s', age='%s')>" % (self.name, self.age)

    id = Column(Integer, Sequence("pet_id_seq"), primary_key=True)
    name = Column(String(50))
    age = Column(Integer)


def update_name(user: User, name: str) -> None:
    user.name = name


def create_pet(session: Session, name: str, age: int) -> None:
    pet = Pet(name=name, age=age)
    session.add(pet)

    try:
        session.commit()
    except Exception as e:
        session.rollback()
        print(e)


def update_age(user: User, age: int) -> None:
    user.age = age


Base.metadata.create_all(engine)

Session = sessionmaker(bind=engine)
session = Session()

new_user = User(name="John Doe", age=30)
session.add(new_user)
session.commit()

user = session.query(User).filter_by(name="John Doe").first()

# 値の集約化として値を格納しておく
user_dict = {}
user_dict.setdefault("name", "Yamada Taro")
user_dict.setdefault("age", 40)

per_dict = {}
per_dict.setdefault("name", "Tama")
per_dict.setdefault("age", 5)

update_name(user, user_dict["name"])
create_pet(session, per_dict["name"], per_dict["age"])
update_age(user, user_dict["age"])
try:
    session.commit()
except Exception as e:
    session.rollback()
    print(e)

print(user)

同様に<User(name='Yamada Taro', age='40')>になるかと思いますが、バグになりそうな点があります。

  • create_petでコミットしてしまっており、もし後続のコミットで失敗した場合、age=30のYamada Taroができてしまう
  • 条件分岐や処理が増えていくと、userモデルが引き回されている中で、いつどこで何をsetされているのかがわかりにくくなる
  • user_dictとper_dictはdictである以上の情報を得ることができないことと、dictはどこからでもsetできてしまうので、いつどこで内容が変わるのかがわからない不安がつきまとう

なので、個人的にリファクタするときは、

  • ValueObjectで属性値を管理しつつイミュータブルにする
  • モデルへの値のsetは別途作成したインターフェースを経由するものとする
  • モデルを引き回さずに、上記で作成したValueObjectを処理中で作成し、モデルを触るときは永続化直前にする
  • メソッド・関数中は見えづらいので、commit()が明示的に必要な部分は本処理中に書く。

改善例は下記です。

from dataclasses import dataclass

from sqlalchemy import Column, Integer, Sequence, String, create_engine
from sqlalchemy.orm import Session, declarative_base, sessionmaker

engine = create_engine("sqlite:///:memory:", echo=False)

Base = declarative_base()


@dataclass(frozen=True)
class UserUpdatesData:
    name: str
    age: int


class User(Base):
    __tablename__ = "users"

    def __repr__(self):
        return "<User(name='%s', age='%s')>" % (self.name, self.age)

    id = Column(Integer, Sequence("user_id_seq"), primary_key=True)
    name = Column(String(50))
    age = Column(Integer)


class UserUpdator:
    def __init__(self, session: Session, user: User):
        self.session = session
        self.user = user

    def exec(self, data: UserUpdatesData) -> None:
        self.user.name = data.name
        self.user.age = data.age


@dataclass(frozen=True)
class PetCreatesData:
    name: str
    age: int


class Pet(Base):
    __tablename__ = "pets"

    def __repr__(self):
        return "<Pet(name='%s', age='%s')>" % (self.name, self.age)

    id = Column(Integer, Sequence("pet_id_seq"), primary_key=True)
    name = Column(String(50))
    age = Column(Integer)


class PetCreator:
    def __init__(self, session: Session):
        self.session = session

    def exec(self, data: PetCreatesData) -> Pet:
        p = Pet(name=data.name, age=data.age)
        self.session.add(p)


Base.metadata.create_all(engine)

Session = sessionmaker(bind=engine)
session = Session()

new_user = User(name="John Doe", age=30)
session.add(new_user)
session.commit()

# ユーザーの更新
u = UserUpdatesData(name="Yamada Taro", age=40)
user = session.query(User).filter_by(name="John Doe").first()
UserUpdator(session, user).exec(u)

# ペットの作成
p = PetCreatesData(name="Tama", age=5)
PetCreator(session).exec(p)
try:
    session.commit()
except Exception as e:
    session.rollback()
    print(e)

print(user)

良くなったと思うことは下記です。

  • UserUpdatesData/PetCreatesDataに値を集約および、値の再設定を許さない形にできた
  • ルール化は必要ですが、値の更新やレコードを追加する操作を別クラスに切り出すことで集約化と見通しが良くなった
  • userモデルがコード中に出てて値更新で連れ回すことが無くなった
  • dataclassで定義しているのでエディタの参照しやすく、補完が効きやすくなった。型チェッカーを入れることで不整合な値を検知することもできる

まだまだ改善点はあると思いますが、以上です。
ありがとうございました。

Discussion