Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Djangoでのプロジェクトだって型ヒントを運用出来る!

 Djangoでのプロジェクトだって型ヒントを運用出来る!

mizzsugar

July 03, 2021
Tweet

More Decks by mizzsugar

Other Decks in Programming

Transcript

  1. Djangoでのプロジェクトだって
    型ヒントを運用出来る!
    2021-07-03
    @Django Congress 2021
    mizzsugar0425
    1

    View full-size slide

  2. Agenda
    ● Pythonで型ヒント使うメリット・デメリット
    ● Djangoでの型ヒントの使い方
    ● Django製プロジェクトの開発現場の型ヒント
    ○ 導入前に決めたこと
    ○ 実際に使ってみて困ったこと
    ○ より快適に型ヒントを運用するために
    ● おまけコーナー
    2

    View full-size slide

  3. お前、誰よ?
    ● Twitterはこのアイコン (@mizzsugar0425)→
    ● Python歴3年
    ● Spready株式会社 (we are hiring!!)
    ● 好き: コーヒー、自転車、ビール
    ● 静岡住みでフルリモートで就業中
    3

    View full-size slide

  4. Spready株式会社について
    設立: 2018-05-01
    ミッション: やりたいに出会い続ける世界をつくる
    ビジョン: 人と組織の新しい“つながり”をつくる
    4

    View full-size slide

  5. Spready株式会社について
    5
    コラボレーションSNS
    『Spready』
    日本で初めてのソーシャルキャピタルの思想を
    個人向けプロフィール
    『Profiee』
    相互理解サポートツール
    『Profiee Teams』

    View full-size slide

  6. では、型ヒントの話へ…
    6

    View full-size slide

  7. 前提
    ● 型ヒントの文法については説明しません。
    ● Djangoのお作法ついては説明しません。
    ● 資料をみやすくするために一部PEP8に沿っていなかったりimportを省いている
    ソースコードがあります。
    ● サンプルコードの技術スタック
    ○ Python 3.9.5
    ○ Django 3.2.5
    ○ mypy 0.910
    ○ django-stubs 1.8.0
    https://github.com/mizzsugar/django-congress-jp-2021
    7

    View full-size slide

  8. 本発表の対象者
    ● Pythonでの型ヒントの基本的な文法を知っている人。
    ● Django Tutorialの内容を理解しているレベルの人。
    ● Djangoのプロジェクトで型ヒントを使いたい人。
    ● チーム開発でDjangoと型ヒントの折り合いをどこまでつけたかの
    事例を知りたい人。
    8

    View full-size slide

  9. 本発表のゴール
    ● チーム開発で型ヒントを導入するメリットを理解出来ている。
    ● Djangoのプロジェクトで型ヒントを書けるようになっている。
    ● Djangoのプロジェクトでの型ヒントで妥協しない点 / 妥協する点の
    判断材料を得ている。
    9

    View full-size slide

  10. Pythonでの型ヒントとは?
    ● typingモジュールがPython 3.5で追加されました。
    ● つけるかつけないかは任意。
    10
    def add(a: int, b: int) -> int:
    """aとbを足し合わせた数字を返します。
    """
    return a + b

    View full-size slide

  11. 型ヒントのメリット・デメリット
    メリット
    ● 型違反による不具合を防げます。
    ● 静的解析に裏付けられているとコードリーディング/リファクタリングが
    より捗ります。
    デメリット
    ● 間違った型ヒントでもプログラムは動くので返って混乱の原因になるかも。
    ● 型ヒントに不慣れなメンバーのみで構成されていると開発速度が遅くなるかも。
    11

    View full-size slide

  12. Djangoでの使い方 1
    pip install mypy django-stubs
    12
    mypy
    Pythonでの型ヒントが正しいかどうか解析するツール
    django-stubs
    Djangoに型を提供している、mypyのプラグイン

    View full-size slide

  13. Djangoでの使い方 2
    mysite
    ├── blog
    │ ├── admin.py
    │ ├── apps.py
    │ ├── migrations
    │ ├── models.py
    │ └── views.py
    ├── manage.py
    ├── mypy.ini
    └── mysite
    ├── __init__.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py
    13

    View full-size slide

  14. Djangoでの使い方 3
    mypy.iniに静的解析する内容を記載します。
    mysite/mypy.ini
    14
    [mypy]
    plugins = mypy_django_plugin.main
    [mypy.plugins.django-stubs]
    django_settings_module = "mysite.settings" 複数設定モジュールがある場合は
    トップレベルのモジュールを指定

    View full-size slide

  15. Djangoでの使い方 4
    blog/models.py
    15
    class Post(models.Model):
    author = models.ForeignKey(settings.AUTH_USER_MODEL,   
    on_delete=models.CASCADE)
    title = models.CharField(max_length=200)
    text = models.TextField()
    created_date = models.DateTimeField(default=timezone.now)
    published_date = models.DateTimeField(blank=True, null=True)
    def __str__(self) -> int:
    return self.title

    View full-size slide

  16. Djangoでの使い方 5
    >> mypy blog/models.py
    blog/models.py:9: error: Return type "int" of "__str__" incompatible
    with return type "str" in supertype "object"
    blog/models.py:10: error: Incompatible return value type (got "str",
    expected "int")
    16

    View full-size slide

  17. Djangoでの使い方 6
    17
    def f(post: Post) -> int:
    return post.title
    def f2(post: Post) -> str:
    return post.dummy_attribute
    blog/models.py:118: error: Incompatible return value type (got "str", expected "int")
    blog/models.py:121: error: "Post" has no attribute "dummy_attribue"

    View full-size slide

  18. Djangoでの使い方 7
    blog/models.py
    18
    class Post(models.Model):
    author = models.ForeignKey(settings.AUTH_USER_MODEL,
    on_delete=models.CASCADE)
    title = models.CharField(max_length=200)
    text = models.TextField()
    published_date = models.DateTimeField(blank=True, null=True)
    @classmethod
    def published_list(cls) -> models.QuerySet[Post]:
    return cls.objects.filter(published_date__lte=timezone.now())

    View full-size slide

  19. Djangoでの使い方 8
    blog/models.py
    19
    from typing import Optional
    def get_by_id(post_id: int) -> Post:
    return Post.objects.get(pk=post_id)
    def get_by_id_use_filter(post_id: int) -> Optional[Post]:
    return Post.objects.filter(id=post_id).first()

    View full-size slide

  20. 喝を入れるなど
    ● 大丈夫? 生きてる?
    ● 水飲もうね
    ● 明日はJIMOTOフラペ! 蕎麦! そしてお土産に日本酒とワインを!
    ● 蕎麦といえば静岡の戸隠そばも美味しいから静岡にもぜひ来てね
    ● ガッツで後半乗り切ろうね
    21

    View full-size slide

  21. Spready株式会社での型ヒント運用
    22

    View full-size slide

  22. 今回例として紹介するサービス
    23
    https://spready.jp

    View full-size slide

  23. Spreadyというサービスについて
    ● 2019年5月ローンチ。
    ● 2020年1月に大規模なリニューアルをしました(ソースコードに一部名残あり)。
    ● 弊社のサービスの中でもっとも古いサービスだけれども
    まだまだ新規開発が必要な段階で開発にスピード感が求められています。
    24

    View full-size slide

  24. 型ヒント運用開始前のSpready
    ● 型ヒントや自動テストの文化がありませんでした。
    ● 仕様の知識が属人化されていました。
    ● ビジネスロジックがviewとmodelに変則的に散らばっており、処理を追うのが困難
    でした。
    ● 型違反による不具合が起こりやすい箇所がちらほら。
    「get_time」という関数の返り値が datetime.dateような
    ● 処理を理解するために隅から隅までソースコードを読まないといけません。
    ● 自動テストがないので変更のための動作確認に時間がかかります。
    25

    View full-size slide

  25. ソースコードを改善することに決めた理由
    ● 現状のまま実装を進めてもスピードは上がらないこと。
    ● まだまだ変更や新規開発が必要なフェーズでスピードが必要。
    ● ソースコードの可読性を上げないとスピードが上がらないと判断。
    26

    View full-size slide

  26. ディレクトリ構成
    27
    発表用のために一部省略しています。
    app
    ├── spready
    │ ├── admin.py
    │ ├── apps.py
    │ ├── migrations
    │ ├── models
    │ ├── utils (Django関係ないライブラリ群)
    │ └── views
    ├── manage.py
    └── settings
    ├── settings.py
    ├── urls.py
    └── wsgi.py

    View full-size slide

  27. ソースコード改善の方針
    ● usecasesモジュールを作成し、ビジネスロジックを集結させることで
    処理の内容追いやすくします。
    ● usecasesモジュールを機能毎のサブモジュールに分割します。
    28

    View full-size slide

  28. 新・ディレクトリ構成
    29
    発表用のために一部省略しています。
    app
    ├── spready
    │ ├── admin.py
    │ ├── apps.py
    │ ├── migrations
    │ ├── models (クエリとモデル定義のみ)
    │ ├── utils (Django関係ないライブラリ群)
    │ ├── usecases (ビジネスロジックはここに集結)
    │ └── views (HTTPの出入り口)
    ├── manage.py
    ├── mypy.ini
    ├── tests
    └── settings
    ├── settings.py
    ├── urls.py
    └── wsgi.py

    View full-size slide

  29. 新・ディレクトリ構成
    30
    発表用のために一部省略しています。
    usecases
    ├── spreader
    │ ├── project.py (案件探しやタグ付け)
    │ ├── casting.py (携わる案件マッチング)
    └── company
    ├── contract.py (契約周り)
    ├── project.py (案件作成・編集)
    └── casting.py (案件の協力者マッチング)

    View full-size slide

  30. 新・ディレクトリ構成
    31
    views
    usecases
    models utils

    View full-size slide

  31. 型ヒントを導入することに決めた理由
    ● 型ヒントがあるとどんな関数・クラスなのか理解しやすく、処理を追いやすい
    ● 型ヒントによって裏付けられているとリファクタリングしやすい
    ● ビジネスロジックには型ヒントをつけようという結論に
    32

    View full-size slide

  32. 型ヒントに期待すること
    ● ビジネスロジックを型安全にすることで、頻繁に起こる仕様変更を安心して迎えられ
    ること。
    ● 型安全にすることで安心してリファクタリングを行えるようにすること。
    33

    View full-size slide

  33. 導入前に決めたこと
    ● どのモジュールから型ヒントをつけ始めるか
    ● 型チェックのためにgitのpre-commitフックを使うか
    ● mypyの --strict オプションを使うか
    ● いつからCIで型チェックをするか
    34

    View full-size slide

  34. どのモジュールから型ヒントをつけ始める?
    ● 色んなところで使っているmodels?
    ● Djangoのお作法に左右されないutils?
    →型ヒントの導入のしやすさから、utilsから開始。
     次にviewsもusecasesも依存するmodels。そしてusecases。最後にviews。
    35

    View full-size slide

  35. gitのpre-commitフックを使う? 1
    gitのpre-commitフックとは…
    ● git commit をフックにして実際にcommitする前に
    指定されたスクリプトを実行する機能。
    ● git commit をフックで型チェックをして、エラーがあったら実際にcommitされない仕
    組みを作れます。
    https://bit.ly/3AvTGXJ
    36

    View full-size slide

  36. gitのpre-commitフックを使う? 2
    メリット
    ● CIにmypyを組み込んでいなくてもコミットしたら
    自動的にチェックしてくれます。
    デメリット
    ● モジュールごとのチェックなので、コミット内容と関係ない行のエラーも残ります。
    ● エラーがあっても強制的にコミット出来る機能があります。
    しかし、強制コミットする運用なのは望ましくありません。
    →エラー残した状態で強制コミットは避けたいが
    関係ないところも直してコミットするのはレビューワーが大変。
    ということで、pre-commitで型チェックしないことに。
    37

    View full-size slide

  37. --strictオプションを使う? 1
    ● mypyコマンドに --strict をつけることで、より厳しい静的解析が行われます。
    ● デフォルトで指摘されるのは以下
    ○ Optionalと定義していない引数に Noneを渡してはいけない (--strict-optional)
    ○ 戻り値の型ヒントがNoneとAny以外の時にreturnがないといけない(--warn-no-return)
    ○ グローバル変数に型をつけて定義しないといけない (--disallow-untyped-globals)
    ○ 一度定義した変数を別の型のオブジェクトとして再定義してはいけない (--disallow-redefinition)
    https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict
    38
    ドキュメントで「デフォルトで指摘されるのはこれ」と明示されているわけではないのでまだあるかも …。

    View full-size slide

  38. --strictオプションを使う? 2
    ● --strictオプションとDjangoORMの親和性󰢄(だと知識が不足していた当時は思っ
    ていました)(今回の発表で調べているうちに解決したから、おまけコーナーに書きま
    した!)
    -> 何回も # type: ignore またはcastを書かないといけなくなります。
    ● まずは自分たちが定義した関数・メソッドに正しい型を明示することが大事。
    -> デフォルトの設定で足りそう!
    チームの方針としては --strict オプションなしで!
    ※個々人で頑張ってみるのはOK󰢐
    --disallow-untyped-defs など標準でチェックされない項目だが
    指摘されたい項目はmypy.iniに追加。
    39

    View full-size slide

  39. CIで型チェクするのはいつから?
    ● mypyにチェックしてもらえる最小単位はモジュール。
    ● utilsの1モジュールだけから? -> utilsにあるのは6モジュールほど
    →utilsのすべてのモジュールを一気に型ヒントを与えてからスタート。
    ● 徐々にmodels, usecasesに型ヒントを与え、CIの対象に。
    40

    View full-size slide

  40. 実際に導入して困ったこと・対策
    ● AbstractBaseUserモデルを継承したUserモデル
    ● RelatedObjectDoesNotExist
    ● QuerySetのvaluesやvalues_list
    ※Spready以外の弊社サービスでの開発中に起こったことも含んでいます。
    ※これから説明に使うサンプルコードは、Spready株式会社で実際に書かれているソー
    スコードではありません。発表用に作成した、架空のブログサービスです。
    41

    View full-size slide

  41. AbstractBaseUserとは
    ● DjangoのUserモデルをカスタマイズするためのクラス。
    ● AbstractBaseUserモデルを継承したクラスは
    Userモデルがもつ認証機能などをもっています。
    https://docs.djangoproject.com/ja/3.2/topics/auth/customizing/
    42

    View full-size slide

  42. AbstractBaseUserクラスを継承したモデルのpassword
    class User(AbstractBaseUser):
    email = models.EmailField()
    password = models.TextField(null=True, blank=True)
    facebook_user_id = models.CharField(max_length=255, null=True,
    blank=True)
    43
    blog/models.py

    View full-size slide

  43. 継承元のAbstractBaseUserの型定義 vs overrideした定義
    >> mypy blog/models.py
    blog/models.py:47: error: Incompatible types in assignment (expression
    has type "TextField[Union[str, Combinable, None], Optional[str]]", base
    class "AbstractBaseUser" defined the type as "CharField[Union[str, int,
    Combinable], str]")
    44
    もともと、passwordはNOT NULLのCharFieldで、型ヒントはstr型です。
    カスタマイズしたpasswordはNULLABLEはTextFieldで、型ヒントはOptional[str]型で
    す。

    View full-size slide

  44. passwordの型違反は妥協!
    class User(AbstractBaseUser):
    email = models.EmailField()
    password = models.TextField(null=True, blank=True) # type: ignore
    facebook_user_id = models.CharField(max_length=255, null=True,
    blank=True)
    45
    blog/models.py

    View full-size slide

  45. Userモデルのpasswordは直接使われない想定
    user.set_password(raw_password: Optional[str])
    -> 引数はOptional[str]
    -> OAuth認証での新規登録の場合はset_passwordを呼び出さないので
    Noneが渡されることはない。
    user.check_password(raw_password: str)
    -> 引数はstr
    -> そもそもNone同士を比較しようとすると型違反。
    user.passwordという風には使われない想定。
    https://github.com/typeddjango/django-stubs/blob/5c3898d3b0ce22942b2141f36f1e084e226d0f30/django-
    stubs/contrib/auth/base_user.pyi
    46

    View full-size slide

  46. ユーザー定義メソッドを追加
    class User(AbstractBaseUser):
    class Status(models.TextChoices):
    ACTIVE = ('user_status_active', 'Active')
    DEACTIVATED = ('user_status_deactivated', 'Deactivated')
    objects = UserManager()
    status = models.CharField(
    max_length=255,
    choices=Status.choices,
    )
    email = models.EmailField()
    ...
    def is_active(self) -> bool:
    return self.status == User.Status.ACTIVE
    47

    View full-size slide

  47. blog/models.py:76: error: Signature of "is_active" incompatible with
    supertype "AbstractBaseUser"
    48
    AbstractBaseUserの型定義ではis_activeというbool型のアトリビュートを定義している
    ため、衝突してエラーになりました。

    View full-size slide

  48. def is_active(self) -> str: # type: ignore
    return self.status == User.Status.ACTIVE
    49
    blog/models.py:77: error: Incompatible return value type (got "bool",
    expected "str")
    ● 名前が衝突したためのエラーは解消。
    ● メソッドの返り値など、処理の内容の誤りはチェックしてくれています。
    -> カスタムメソッドに # type: ignore をつけることに。
    ※影響範囲が広かったので # type: ignoreにしたけどメソッド名を変えるのが一番良いと思います。
    ※そもそも、同じ名前のメソッド・アトリビュートをつけないようにしましょう。

    View full-size slide

  49. RelatedObjectDoesNotExist
    class Profile(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    comment = models.CharField(max_length=255)
    birthday = models.DateField()
    def f(user: User) -> Profile:
    try:
    return user.profile
    except User.profile.RelatedObjectDoesNotExist:
    ...
    50

    View full-size slide

  50. blog/models.py:130: error: "Profile" has no attribute
    "RelatedObjectDoesNotExist"
    51
    Issueもあります!(17日前にあげられた)
    https://github.com/typeddjango/django-stubs/issues/649

    View full-size slide

  51. すぐfixされなさそうなので type: ignore!
    def f(user: User) -> Profile:
    try:
    return user.profile
    except User.profile.RelatedObjectDoesNotExist: # type: ignore
    ...
    52

    View full-size slide

  52. Querysetのvaluesやvalues_list
    def f() -> List[int]:
    # [1, 2, 3, 4]のようなオブジェクトが返されます。
    return User.objects.values_list('id', flat=True)
    53
    blog/models.py:123: error: Incompatible return value type (got "ValuesQuerySet[User, int]",
    expected "List[int]")

    View full-size slide

  53. そうおっしゃるならやってやるさ
    from django.db.models.query import ValuesQuerySet
    def f() -> ValuesQuerySet[User, int]:
    # [1, 2, 3, 4]のようなオブジェクトが返されます。
    return User.objects.values_list('id', flat=True)
    54

    View full-size slide

  54. えっ…😇
    >> mypy blog/models.py
    ImportError: cannot import name 'ValuesQuerySet' from
    'django.db.models.query'
    (/home/mizzsugar/workspace/djangocongress2021/.venv/lib/python3.9/site-pa
    ckages/django/db/models/query.py)
    55

    View full-size slide

  55. もはやなくなっているとは…🤣
    56
    https://github.com/typeddjango/django-stubs/issues/144
    「ValuesQuerySetを使おうとし
    たらDjango1.9でなくなっている
    んだけど…」
    という2019年8月に
    書き込まれたIssue

    View full-size slide

  56. 回避法があるようだが
    https://github.com/typeddjango/django-stubs/issues/144
    57

    View full-size slide

  57. listにすることにした
    58
    from typing import List
    def f() -> List[int]:
    # django-stubsからValuesQuerySet[User, int]が返されるはずと
    # 指摘されるが、ValuesQuerySetはDjango1.9で削除されているため
    # 定義出来ません。
    # listにしたいわけじゃないけど型違反を起こさないためにこうしています。
    # https://github.com/typeddjango/django-stubs/issues/144
    return list(User.objects.values_list('id', flat=True))
    別にlistにしなくてもよいけど仕方なくという実装。
    仕方なし実装をする時にコメント書くようにしていることは、快適に開発するための工夫

    View full-size slide

  58. より快適に型ヒント付けを進めるためにしていること
    ● エディタ・IDEを味方に
    ● ボーイスカウトルール
    59

    View full-size slide

  59. エディタ・IDEを味方に
    Pythonの型ヒントの支援機能を搭載しているエディタ・IDEを使うと
    mypyコマンドを走らせなくても誤りを指摘してくれます。
    60

    View full-size slide

  60. 型ヒント支援機能を搭載しているエディタ・IDE
    VSCode: Pylance
    https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance
    PyCharm
    https://pleiades.io/help/pycharm/type-hinting-in-product.html
    61

    View full-size slide

  61. ボーイスカウト・ルール
    ● 「プログラマが知るべき97のこと」のうちのひとつ。
    ● 「キャンプ場を汚したのが自分でなくても、来た時よりもきれいにしてからその場を
    去る」というルール。
    ● プログラムの世界では、「既存のソースコードを修正する時に、変数名を適切な名
    前に変えるなどの些細な配慮をしよう」という話。
    ● チームメンバー全員がそのようなマインドで取り組むことで
    より高品質ソースコードになるのでは。
    ● 型ヒントがついていないところがあったら積極的につけていこう!
    https://bit.ly/2StEZmz
    62

    View full-size slide

  62. ありがとうございました!!
    63

    View full-size slide

  63. おまけコーナー
    ● QuerySetのエラー解消はいつから?
    ● --strictとDjangoORM
    ● QuerySetは何型?
    64

    View full-size slide

  64. おまけ1: QuerySetのエラー解消はいつから?
    かつて、こう書いていたらエラーになっていました…
    (Python 3.8 + Django 2.2使っていた頃)
    65
    def f() -> models.QuerySet[Post]:
    return Post.objects.all()
    python manage.py runserver
    ...
    TypeError: 'type' object is not subscriptable
    __class_getitem__という
    マジックメソッドがないと
    subscriptableなクラスと
    みなされず
    T[V]という風に定義すると
    実行時にエラーになります。
    QuerySetには
    __class_getitem__が
    実装されていませんでした。

    View full-size slide

  65. Django 3.1で__class_getitem__が実装されました🎉
    66
    https://github.com/django/django/commit/578c03b276e435bcd3ce9eb17b81e85135c2d3f3#diff-d58ef61559dc7af5fdf7b56fee13571a4d29
    48e784cd608f6afeacf3ac2fb195

    View full-size slide

  66. 〜Django 2系での対処方法 1
    文字列にして定義
    実行時には無視され、型ヒントチェック時には評価されるように 。
    67
    def f() -> 'models.QuerySet[Post]':
    return Post.objects.all()
    https://github.com/typeddjango/django-stubs#i-cannot-use-queryset-or-manager-with-type-annotations

    View full-size slide

  67. 〜Django 2系での対処方法 2
    django-stubs-extの力を借りる
    ※__class_getitem__が実装された、Python3.7以上のプロジェクトでないと使えないので注意
    68
    import django_stubs_ext
    django_stubs_ext.monkeypatch()
    settings.py
    https://github.com/typeddjango/django-stubs#i-cannot-use-queryset-or-manager-with-type-annotations

    View full-size slide

  68. おまけ2: --strictとDjangoORM
    class PostQuerySet(models.QuerySet):

    class PostManager(models.Manager):

    class Post(models.Model):
    objects = PostManager()
    ...
    69

    View full-size slide

  69. blog/models.py:81: error: Missing type parameters for generic type "QuerySet"
    blog/models.py:91: error: Missing type parameters for generic type "Manager"
    70

    View full-size slide

  70. おまけ2: --strictとDjangoORM
    class PostQuerySet(models.QuerySet[Post]):

    class PostManager(models.Manager[Post]):

    class Post(models.Model):
    objects = PostManager()
    ...
    71
    Postというクラスなんぞ
    知らないというエラーになる

    View full-size slide

  71. 文字列で定義!
    72
    class PostQuerySet(models.QuerySet['Post']):

    class PostManager(models.Manager['Post']):
    ...

    View full-size slide

  72. おまけ3: QuerySetは何型?
    73
    >>> isinstance(query, List)
    False
    >>> isinstance(query, Sequence)
    False
    >>> isinstance(query, Collection)
    False
    >>> isinstance(query, Iterable)
    True
    >>> isinstance(query, Sized)
    True

    View full-size slide

  73. おまけ3: QuerySetは何型?
    結論: QuerySetはIterableかつSizedなオブジェクトらしい
    74

    View full-size slide