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 Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View 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 Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View 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 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 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 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 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 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 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 Slide

  20. 休憩
    20

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  29. 新・ディレクトリ構成
    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 Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  38. --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 Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  43. 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 Slide

  44. 継承元の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 Slide

  45. 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 Slide

  46. 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 Slide

  47. ユーザー定義メソッドを追加
    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 Slide

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

    View Slide

  49. 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 Slide

  50. 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 Slide

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

    View Slide

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

    View Slide

  53. 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 Slide

  54. そうおっしゃるならやってやるさ
    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 Slide

  55. えっ…😇
    >> 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 Slide

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

    View Slide

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

    View Slide

  58. 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 Slide

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

    View Slide

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

    View Slide

  61. 型ヒント支援機能を搭載しているエディタ・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 Slide

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

    View Slide

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

    View Slide

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

    View Slide

  65. おまけ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 Slide

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

    View Slide

  67. 〜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 Slide

  68. 〜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 Slide

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

    class PostManager(models.Manager):

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

    View Slide

  70. 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 Slide

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

    class PostManager(models.Manager[Post]):

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

    View Slide

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

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

    View Slide

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

    View Slide

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

    View Slide