djangoのviewとurl patternの定義をもう少しstrictな感じにできないかと考えていた
まとまってない感じの文章だけれど備忘録として残す。
djangoでurls.pyにview関数を登録する時に、以下のような状況がだるいと思った。
- urls.pyに定義したview関数を登録し忘れる
- view関数の引数を変えた後に、urls.pyのpatternを変更し忘れている
そしてそもそものurls.pyでview関数を登録するという記述の仕方だと完全に解決できないな〜ということを思った。 逆に、urls.pyの定義をviews.pyに持ち込んでこちらがわでチェックしながら登録することができるかもしれないというようなことを思った。
それのシミュレーションをするようなコードを書いてみた。
djangoの通常のview関数の登録
djangoの場合、views.pyで定義した関数をurls.pyで登録する。(実際の記述方法と今回の記述は異なる)
view.py
def aview(request): return "a" def bview(request, x): return "b", x def c(request, x, y): return "c", x, y
urls.py
# このrootはapplicationで1つだけ(rootに数多くのrouterが属しているイメージ) # 後述するviewのチェックに対応するためにこのような形になっている root = Root() r = Router() root.add(r) r.register("/a", "a", aview) r.register("/b/(?P<x>[^/]+)", "b", bview)
利用するときは以下の通り
class DummyRequest(object): def __init__(self, path): self.path = path def reverse(self, name, **kwargs): rx = root.pattern_from_name(name) return format_from_pattern(rx.pattern).format(**kwargs) print(r(DummyRequest("/a"))) # => a print(r(DummyRequest("/b/xxx"))) # => ('b', 'xxx') print(DummyRequest("/a").reverse("a")) # => /a print(DummyRequest("/a").reverse("b", x="yyyy")) # => /b/yyyy # 登録されていないviewなので使えない。 # print(DummyRequest("/a").reverse("c", x="yyyy", y="zzzz")) # oops
完全に動く定義ができている時には何の問題もないのだけれど。 不完全な定義であった場合に、動かず、該当のurlにブラウザアクセスしなければその間違いに気づかないというのが非常に億劫。
appendix
シミュレーションに使った動作するRouter/Rootの実装
import re class Root(object): def __init__(self): self.routers = [] def __call__(self, request): for r in self.routers: result = r(request) if result is not None: return result def add(self, router): self.routers.append(router) def pattern_from_name(self, name): for r in self.routers: if name in r.name_pattern_map: return r.name_pattern_map[name] class Router(object): def __init__(self): self.name_pattern_map = {} # (name -> pattern) self.name_view_map = {} # (name -> view) def register(self, pattern, name, view): if name in self.name_pattern_map: raise Exception("Conflict name={}".format(name)) self.name_pattern_map[name] = re.compile(pattern) self.name_view_map[name] = view def __call__(self, request): for name, pattern in self.name_pattern_map.items(): m = pattern.search(request.path) if m is not None: request.args = m.groups() return self.name_view_map[name](request, *request.args) def format_from_pattern(pattern): return re.sub("\(\?P<([\w+])>[^(]+\)", lambda m: "{%s}" % m.groups(1), pattern) def extract_args_from_pattern(pattern): return re.findall("\(\?P<([\w+])>[^(]+\)", pattern)
djangoのurl patternのmatchingをviews.py側でやる。
色々やり方があると思ったけれど。とりあえずメタクラスでやることにした。 またオプションは以下のようにMetaを使った形のもので定義することが多いが。以下のような問題があると思っている。
- そもそもコードが冗長になって嫌。
- 必須の引数が何なのかわかりづらい。
# Metaを使った例。 class FooModel(Model): class Meta: app_label = "myapp"
変わりにbaseクラスを生成する関数を作ってこちらを使うようにしたほうが良いのかもしれない。こちらなら必須の引数がなんだかわかりやすい。
FooMeta # metaclass def creat_base_class(x) # x は必須の引数 return FooMeta("FooBase", (), {"x": x})
横道にそれてしまった。今回は、ViewDefinitionというクラスに所属する関数(not method)がview関数ということにする。 (現在の制限としてはclass base viewが使えないというものがある。これは実装を工夫すれば解決できそう)
これで以下ようなことが出来るようにする。
- view関数を定義したがurls.pyでpatternと結びつけるのを忘れた
- urls.pyでpatternと結びつけたが、view関数を定義するのを忘れた
- urls.pyでpatternと結びつけたがview関数のパラメータと一致していない
これらは全て現状のdjangoではruntime時に該当のurlにアクセスしてみない限りわからない。
以下の様な形で使う。
urls.py
root = Root() r = Router() root.add(r) r.register_pattern("/a", "a") r.register_pattern("/b/(?P<x>[^/]+)", "b") # cに対応するviewが定義されていたがpatternとmatchingが定義されていなかった場合のエラー # Exception: view[name=c] is not registered. plese router.register_pattern(pattern, 'c') r.register_pattern("/c/(?P<x>[^/]+)/(?P<y>[^/]+)", "c")
views.py
from .urls import r class ViewDefinition(make_view_definition(router=r)): def a(request): return "a" def b(request, x): return "b", x # cについてpatternとの対応が定義されていたがview関数が登録していなかった場合のエラー # Exception: view[name=c] is not defined. please def c(request, x, y): # cについて対応も定義されていたが正規表現とview関数の引数が一致しなかった場合のエラー # Exception: view[name=c] is invalid definition(pattern_args=['x', 'y'] != view_args=['x']) def c(request, x, y): return "c", x, y
同様な形で使えます。
print(r(DummyRequest("/a"))) # => a print(r(DummyRequest("/b/xxx"))) # => ('b', 'xxx') print(r(DummyRequest("/c/xxx/yyy"))) # => ('c', 'xxx', 'yyy') print(DummyRequest("/a").reverse("a")) # => /a print(DummyRequest("/a").reverse("b", x="yyyy")) # => /b/yyyy print(DummyRequest("/a").reverse("c", x="yyyy", y="zzz")) # => /c/yyyy/zzz
良いこと
- viewの登録忘れが解消される
- viewとpatternの引数の対応が確保される
問題点
- (ある1つのrouterの範囲でしか網羅性をチェックできない)
- class base viewに対応できない。(これどうにか出来るかも)
- わざわざクラスを作らないといけない。
今回はmetaclassで定義したけれどViewImporterというかViewResolverのようなものを定義してあげてそれに任せるというような形にすることでも対応出来るかもしれない。
実際に動く定義
class Router(object): def __init__(self): self.name_pattern_map = {} # (name -> pattern) self.name_view_map = {} # (name -> view) def register_pattern(self, pattern, name): if name in self.name_pattern_map: raise Exception("Conflict name={}".format(name)) self.name_pattern_map[name] = re.compile(pattern) def register_view(self, view, name): self.name_view_map[name] = view def __call__(self, request): for name, pattern in self.name_pattern_map.items(): m = pattern.search(request.path) if m is not None: request.args = m.groups() return self.name_view_map[name](request, *request.args) class ViewDefinitionMeta(type): @classmethod def extract_attribute(cls, name, attrs, bases): if name in attrs: return attrs[name] for c in reversed(bases): if hasattr(c, name): return getattr(c, name) @classmethod def configure_from_router_information(cls, router, attrs, bases): """check: view function from router information""" for name, rx in router.name_pattern_map.items(): pattern_args = extract_args_from_pattern(rx.pattern) view = cls.extract_attribute(name, attrs, bases) if view is None: fmt = "view[name={}] is not defined. please def {}(request, {}):" raise Exception(fmt.format(name, name, ", ".join(pattern_args))) import inspect view_args = inspect.getargspec(view).args[1:] if tuple(view_args) != tuple(pattern_args): fmt = "view[name={}] is invalid definition(pattern_args={} != view_args={})" raise Exception(fmt.format(name, pattern_args, view_args)) # register: view function router.name_view_map[name] = view @classmethod def configure_from_view_information(cls, router, attrs): """check: router information from view function""" for name, attr in attrs.items(): if name.startswith("_"): continue if callable(attr) and not isinstance(attr, Router): view = attr if name not in router.name_pattern_map: fmt = "view[name={}] is not registered. plese router.register_pattern(pattern, {!r})" raise Exception(fmt.format(name, name)) # makes not to Class.<name> is unbound method. attrs[name] = staticmethod(view) def __new__(cls, clsname, bases, attrs): if bases: router = cls.extract_attribute("router", attrs, bases) if router is None: raise Exception("router is not defined") cls.configure_from_router_information(router, attrs, bases) cls.configure_from_view_information(router, attrs) return super(ViewDefinitionMeta, cls).__new__(cls, clsname, bases, attrs) def make_view_definition(router): return ViewDefinitionMeta("ViewDefinition", (), {"router": router})