今日の気づき
「夜に駆ける」をRacing Into The Nightと英訳するとユーロビートっぽい。
環境変数から設定値を読み取るPythonのメタクラス
アプリケーションの設定を環境変数経由で渡すし、個別の設定にはデフォルト値がある、というシチュエーションを考える。
素朴には os.environ.get("FOO", "default_value")
みたいなのを書けば設定値を取得できる。が、これにはいくつか問題がある。
まず、あまりに素朴に実装すると os.environ.get("FOO", "default_value")
がコピペされまくることになる。これでは環境変数の名前やデフォルト値を修正したくなったときに確認すべき箇所が多くなる。
設定値を書く用のファイルを用意して foo = os.environ.get("FOO", "default_value")
みたいに書きまくると多少はましになる。が、まだ環境変数名とPythonでの変数名を重複して書いているのでもうちょっと短く書きたい。また、たとえばテストなどで環境変数の値を書き換えるときも、importの順序次第でうまく書き換えられないことがある。
環境変数から値を読んで返す関数を定義すれば値を書き換えられるようになるけど、設定値を読み取るためのコードが長くなってしまう。
ということで、表題のソリューションを用意した。まずはこちらをご覧ください*1。
import os import re from typing import List from app.util import bool_from_env_var VARIABLE_PATTERN = re.compile(r"[a-z][0-9a-z_]*") def _create_property(env_var_name, default_value, var_type="str"): def getter(self): if var_type == "bool": # 環境変数の値 (文字列) をいい感じにboolに変換する return bool_from_env_var(os.environ.get(env_var_name, "")) elif var_type == "int": return int(os.environ.get(env_var_name, default_value)) else: return os.environ.get(env_var_name, default_value) def setter(self, new_value): if isinstance(new_value, bool): os.environ[env_var_name] = "1" if new_value else None else: os.environ[env_var_name] = str(new_value) return property(getter, setter) class ConfigFromEnvironmentVariableMeta(type): def __new__(cls, classname, bases, dic): variables: List[str] = [k for k in dic if VARIABLE_PATTERN.match(k)] new_dic = {k: v for k, v in dic.items() if k.startswith("__")} for variable in variables: default_value = dic[variable] if callable(default_value): new_dic[variable] = default_value continue env_var_name = variable.upper() var_type = type(default_value).__name__ new_dic[variable] = _create_property(env_var_name, default_value, var_type) return type.__new__(cls, classname, bases, new_dic) class ConfigFromEnvironmentVariable(metaclass=ConfigFromEnvironmentVariableMeta): pass
ConfigFromEnvironmentVariable
クラスを継承した設定値用のクラスを作って、以下のように使うことができる。直接 metaclass
を指定してもよいけど、メタクラスよりは継承のほうが馴染みのある概念だと思うのでラップした。
import os class AppConfig(ConfigFromEnvironmentVariable): # 環境変数が設定されていない場合のデフォルト値を指定できる base_url = "http://localhost:3000/" debug = False app_config = AppConfig() app_config.base_url # => "http://localhost:3000/" # 値をセットすると設定値も書き換えられる (裏では環境変数を書き換えている) app_config.base_url = "http://localhost:3000/test/" app_config.base_url # => "http://localhost:3000/test/" # 型をよしなに見て空気を読んでくれる app_config.debug # => False app_config.debug = True app_config.debug # => True
原理
__new__
は __init__
の前に呼び出されるクラスメソッドで、いろいろ引数を受け取るけど注意すべきは第3引数の dic
である。
dic
にクラス変数とその値が入っているので、デフォルト値と型を読み取ったあとpropertyに置き換えている。クラス変数以外も入っているので正規表現マッチでフィルタリングしている。ただしメソッドや __
で始まるフィールドは保持している。
property
クラスでアクセサを定義している。デコレータとして使うことが多いかもしれないけどこういう使い方もできる。
原理を完全に把握するにはCPythonのコード*2に立ち入るしかなさそうだった。公式のドキュメントを読んでみたけど __new__
メソッドの引数についての言及を見つけられなかった。
参考
以下の記事を見つけて大いに参考にさせてもらった。紹介されているのはPython 2のコードだけど、ちょっと雰囲気を揃えたらPython 3でも動く。
2022/4/24 追記: 同様のライブラリを探した
myenvというライブラリがまさに同じようなことを実現できる作りになっていそう。実装もだいたい似たようなことをやっているようだった。
■
ふつうの日記を長らく書いていなかったので、書く。
緊急事態宣言が解けたけど、お店はマンボーで19時ラストオーダーのところが多くて、したがって週末ぐらいしか飲みに行けない。もうちょっとの辛抱ではありそう?
ワクチンを打ちたいけど、直近の情勢を見るにいつ打てるかはなかなか確定しなさそう。副反応がひどいとしょんぼりしそうだけど、副反応が出ると「しっかりやってるね~」という感じになりそう。副反応が出たときの休暇制度があるのでバシバシ活用したい。
週末にBREWDOG PUNK IPAを飲んで気持ちを高める暮らしを続けている。この世にまたとない最高の飲み物だと思う。
ここで一句
給料爆上がりしてほしいな
— うたがわきき (@utgwkk) 2021年7月2日
Relay + TypeScriptでnodeクエリを使うときは__typenameフィールドを明示的に取得すると便利
タイトルが全てです。
作品詳細ページで、id
をもとに作品 (Artwork
) を取得したいとする。
Artwork
が Node
インタフェースを実装しているなら、以下のように node
クエリを使って取得できる。
query ArtworkDetailQuery($id: ID!) { artwork: node(id: $id) { ... on Artwork { id title caption } } }
が、このクエリをもとにrelay-compilerでTypeScriptのコードを生成すると、以下のように artwork
のフィールドが全てoptionalになった型定義が生成されてしまい、不便である。
export type ArtworkDetailQueryResponse = { readonly artwork: { readonly id?: string; readonly title?: string; readonly caption?: string; } | null; };
こういう場合は、node
クエリで取得する Node
オブジェクトの __typename
フィールドを取得すればよい。
query ArtworkDetailQuery($id: ID!) { artwork: node(id: $id) { __typename ... on Artwork { id title caption } } }
こうするとrelay-compilerが以下のようなunion typeを出力してくれる。使う際は __typename
で型を絞り込むことができる。
export type ArtworkDetailQueryResponse = { readonly artwork: ({ readonly __typename: "Artwork"; readonly id: string; readonly title: string; readonly caption: string; } | { /*This will never be '%other', but we need some value in case none of the concrete values match.*/ readonly __typename: "%other"; }) | null; };
const { artwork } = usePreloadedQuery(...); if (!(artwork && artwork.__typename === "Artwork")) { return <div>artwork not found</div>; } // artworkはArtwork型のオブジェクトであることが確定する
このコンパイラの挙動はrelay-compiler 11.0.2で確かめた。めっちゃフィールドがoptionalになるけどなんとかしたいね、と思って試しに __typename
を取得してみたらこの挙動に気づいた。
入力フォームに対してIMEの確定を待ってからEnterでなんかするやつ
入力フォームがあって、Enterを押して入力したテキストをリストに追加したいとしましょう。↓こちらでお試しできます。
- ここに追加されるよ
こういう感じで実装できます。簡単ですね。
(() => { const list = document.querySelector("#list_1"); const input = document.querySelector("#input_1"); input.addEventListener("keydown", (e) => { if (e.key !== "Enter") { return; } e.preventDefault(); const text = e.target.value; const li = document.createElement("li"); li.textContent = text; list.appendChild(li); e.target.value = ""; }); })();
ところでこの実装には問題があって、IMEの変換をEnterで確定すると、環境によっては確定直前? 直後? の文字がそのまま追加されてしまうことがあります。
以下はMacのGoogle Chrome 91での動作例です。AquaSKKの確定直前の文字 ▼犬
が追加されてしまっています。これはSKKに限らず、たとえばMacデフォルトのIMEでも同じ問題が発生します。困りましたね。
IMEの変換中はEnterを押してもリストに追加されてほしくないですね。いいプロパティがないか探してみると KeyboardEvent.isComposing
というものがあることに気づきます。
IMEによる変換中は真を、そうでない場合は偽を返すようです。これは使えそうですね。そうして改良した入力フォームが↓こちらです。
- ここに追加されるよ
(() => { const list = document.querySelector("#list_2"); const input = document.querySelector("#input_2"); input.addEventListener("keydown", (e) => { if (e.key !== "Enter" || e.isComposing) { return; } e.preventDefault(); const text = e.target.value; const li = document.createElement("li"); li.textContent = text; list.appendChild(li); e.target.value = ""; }); })();
これでMacのChromeでも ▼犬
が入力されずに済みました。よかったですね。
ところで、改良した実装でもまだ問題になるケースがあります。Mac Safariで 犬と猫
という文字を追加してみましょう……。
おや、まだ 犬
しか入力していないのにリストに追加されてしまいました。どうやらSafariでは keydown
イベントの isComposing
プロパティを見る方法でもうまくいかないようです。
2019年の情報ですが、以下の記事が参考になります。Safariでは「Enter押下で確定」時の keydown
イベントの isComposing
が真にならないようでした。
なんとかできないか、とちょっと考えた末に以下の作戦を考えました。
compositionstart
イベントとcompositionend
イベントを自前で監視して、compositionend
イベントが発火した直後かどうかを判定できるようにする- Safariでは
↑の判定をcompositionend
イベントの発火直後のkeydown
イベントを間引くKeyboardEvent.isComposing
の代わりに使う- 2021/6/30: 「
keydown
イベントを間引く」よりも「自前でcompositionend
イベントを監視して判定する」の方が表現として適切そうだったので修正しました
- 2021/6/30: 「
これを試したのが↓の入力フォームです。いかがでしたか?
- ここに追加されるよ
(() => { const list = document.querySelector("#list_3"); const input = document.querySelector("#input_3"); // SafariっぽいUAのとき、compositionend イベントの直後かどうか判定できるようにする const isSafari = navigator.userAgent.includes("Safari/") && navigator.userAgent.includes("Version/"); let isCompositionFinished = true; input.addEventListener("keydown", (e) => { if (isSafari && isCompositionFinished) { isCompositionFinished = false; return; } if (e.key !== "Enter" || e.isComposing) { return; } e.preventDefault(); const text = e.target.value; const li = document.createElement("li"); li.textContent = text; list.appendChild(li); e.target.value = ""; }); input.addEventListener("compositionstart", () => { isCompositionFinished = false; }); input.addEventListener("compositionend", () => { isCompositionFinished = true; }); })();
こういうことをしなくても済むようになりたいですね。もっといい方法があれば教えてください。
趣味でGraphQL APIを使ったwebアプリケーションを作ってみた感想
GraphQL APIを備えたwebアプリケーションを趣味で作っていて、完成したので感想をまとめる。
目次
作ったアプリケーション
サークル内で使うイラストアップローダーを作った。機能としてはこういう感じ。有り体に言えばclosedなpixivである。
- 作品をアップロードできる
- 作品には1個以上のイラストがある
- 作品に「いいね」ができる
- 作品にコメントできる
- 作品にタグを付けられる
- タグで作品を検索できる
もともと同様のアプリケーションが動いていたのだけれど、今回それをリプレイスする形で実装した。
使ったライブラリ
Flask
Pythonの薄いwebアプリケーションフレームワークである。RubyでいうところのSinatraポジション。使い慣れているので採用した。
SQLAlchemy
これまではO/Rマッパーは使わず生SQLをゴリゴリ書いていく主義だったのだけれど、SQLを気にして書く段階から脱却したいなと思ったので採用した。
結論としては、Graphene-Pythonと連携してかなり楽に実装できたと思う。declarative_base()
を使ってモデル定義をPythonのクラスとして書き下す感じで、最初はぎこちなく書いていたけどだんだん世界観が分かってきた。
知見をいくつかScrapboxにまとめている。典型的な多対多関係とか、一対多関係とは別に関係を保持しておきたいとか、こういうユースケースはあると思う。SQLAlchemyのドキュメントがかなり詳しいので、ユースケースに合った書き方が分からないときは関連していそうな箇所をじっくり読むのがよさそう。
alembic
SQLAlchemyのマイグレーションツール。
ActiveRecordだとDBマイグレーション機能があるけどSQLAlchemy本体にはなくて、頑張るか何らかのライブラリを併用することになると思う。alembicはSQLAlchemyの開発チームが開発していて、信頼感がある。
SQLAlchemyのモデル定義を書き換えたあと、alembicでマイグレーションスクリプトを生成する、という方法が典型だと思う。スキーマファーストで差分を生成していくみたいなイメージ?
Graphene-Python
PythonのGraphQLフレームワーク。FlaskやSQLAlchemyを含めた各種フレームワークと連携できる機能がある。RelayのGraphQL Server Specificationに準拠するのも難なくできた。
graphene-file-uploadを使うとMultipart Request Specificationに沿ったファイルアップロードも実装できる。
作ってみてどうだったか
思ったよりも楽に作れたと思う。素朴に実装するとN+1クエリの温床になるけど、dataloaderを適切に差し込むとうまくクエリをまとめてくれる。Multipart Request Specificationに準拠するのは最初けっこうハマって、うまくファイルアップロードできなくて結局Flaskのrequestオブジェクトを直接見ていたけど、やり方が間違っていたことに気づいて修正できた。ここに関しては別途記事にしたい。
2021/7/18 追記: ファイルアップロードについての記事を書いた。
作品詳細ページにはいろいろな情報を出したい、というときに、従来なら必要な情報を頑張ってまとめて集めてくる (N+1クエリに気をつけつつ) ことになると思うけど、GraphQL APIだとクエリを1つ書いて適切なフィールドを要求したらできた。↓みたいなクエリと同じことをバックエンドで頑張っていたのか、と思うと感慨深い。
query ArtworkDetailQuery($id: ID!) { node(id: $id) { ... on Artwork { id title caption createdAt account { id name } illusts { edges { node { id imageUrl } } } likes { edges { node { account { id name } } } } comments { edges { node { text createdAt account { name } } } } tags { edges { node { id name } } } } } }
fragmentで共通部分をくくり出せるのもよくて、作品一覧ページの個別作品コンポーネントをrenderするのに必要な情報をfragmentにしておくと再利用できて便利。どこまで分けるかはけっこう思想があるかもしれない。fragmentにしておくと親コンポーネントがフィールドの詳細を知らなくてもよい、と思って作ってみているけどこれでいいのかは分からない。
POSTリクエストがバンバン飛んでくるので、趣味程度ならいいけど、パフォーマンスなど本番導入する際には気をつけることになりそう。とはいえ、重たいクエリを発行できないようにしたり、dataloaderを実装したりすれば案外なんとかなるのだろうか。あるいはGETリクエストで特定のクエリだけキャッシュできるようにするとか?
おわりに
いくつか (6個ぐらい) のモデルがあって互いに関連し合っている、というwebアプリケーションをGraphQLで実装してみた。従来の開発体験とはまた違った感じで新鮮なので、機会があればGraphQL APIを作ってみるとよさそう。