◇ アノキミモノガタリその肆
アノ日のULID、キミとまた出会えるまで十億億回の輪廻のタイムスリップ、A.D.10889年まで。
リソースがないこのいま、タイムマシンは壊れた以上、過去に戻すことはもうできない。
しかし、諦めたくない。諦めてはいけない。
キミは、きっと広大なネット上のどこかで僕とコマちゃんを待っているだろう。
広大なネット上で、きっと僕らの居場所もある。
はじめに
こんにちは、転生したら相変わらずエンジニアだった OLTA(オルタ)プロダクトグループ研究開発チームのタイトルの前置きがとにかく長い B.です。
INVOYカードの開発を担当しています。
今回、UUID*1のRFCがRFC4122からRFC9562に刷新されたことをきっかけに、約2年間眠っていたテックブログの記事を加筆して発表しようと思います。
選択編ではULID移行時に整数型IDと入れ替わるべきか?共存させるべきか?について話しました。工程編ではPythonにおいてULIDの実応用とか、実際のDjango/DRF工程において起こった様々な問題について詳しく述べます。
PythonにおけるULIDの実際応用
以下はPythonの ulid ライブラリを利用した前提の例としてULIDの利用時のイメージです。
もっと安全な乱数生成方式も指定できます。
前述の通り、ULIDは実にはバージョン情報なしのUUIDへも変換できます。
もちろん、逆にUUIDからもULIDへ変換できます。
また、本物のUUID v4の場合と比較してみれば、なぜはULIDベースのUUIDのバージョン情報はデタラメだとすぐに分かると思います。
マイクロ秒まで単調増加できるULID
おまけとして、実には Python の ahawker 版の ulid ライブラリは、マイクロ秒までの精度で単調増加できる機能も実現できました。
ただし、要注意なのはこの機能において、最後の80-bitの最初の10-bitはマイクロ秒用に使われたので、精度はミリ秒からマイクロ秒までに上がった代わりに、同じマイクロ秒で作成されたULIDのp=0.5時にける衝突の試行回数の期待値は343億回まで下がってしまいます。
また残念ですが、こちらは ULID の正式的な仕様ではありません。そこで、この機能の利用には、上記のデメリットを承知した上で、すべて自己責任になります。
ULIDの移行工程で起きた疑問や問題集
クエリの検索問題
なんと、ULIDってクエリで検索できないの?!
ULIDを採用した後に、特にDjango、Django REST Framework (DRF) 、PostgreSQLとdjango-ulidライブラリのULIDFieldと言った組み合わせを利用した場合、とても陥りやすい罠が存在しています。それは、もし安直にDRFのfilters.SearchFilterを利用したら、ULID領域は予想通りに検索できないことです。
検索できない理由
上記引用のように django-ulid/models.py によると、もし利用されているデータベース管理システムはUUIDがサポートされている場合、ULID文字列の代わりに、変換後のversion情報なしのUUID形式でデータベースに保存されることになります。
そこで、もしDRFのfilters.SearchFilterを直接に利用したら、 ULID文字列で、実質的UUID文字列として保存されたデータをクエリしたら、検索できるわけがありません。
ULIDを検索できるようにするための突破口
まずは知っておく必要があるのは、DRFの filters.SearchFilter は実質的にDjango Admin の ModelAdmin.search_fields を利用していることです。
Django のModelAdmin.search_fieldsにおいて、5個のデフォルトの検索方法が提供されました。
Prefix |
Lookup |
説明 |
---|---|---|
None |
icontains |
部分一致 (case insensitive) |
^ |
startswith |
前方一致 (case sensitive) |
= |
iexact |
完全一致 (case insensitive) |
@ |
search |
全文検索 (case sensitive, PostgreSQLのみ) |
一方、DRFの filters.SearchFilter は以下のようにカスタマイズされて、startswithはistartswithに変更され、さらに正規表現用のiregex方法は追加されました。
Prefix |
Lookup |
説明 |
---|---|---|
None |
icontains |
部分一致 (case insensitive) |
^ |
startswith |
前方一致 (case insensitive) |
= |
iexact |
完全一致 (case insensitive) |
@ |
search |
全文検索 (case sensitive, PostgreSQLのみ) |
$ |
iregex |
正規表現一致 (case insensitive) |
使い方として、もし search_field を以下のように定義しました。
実際のDjango querysetはそれぞれに以下のようになります。
PostgreSQLではUUIDに対して専用なUUID Data Typeがあるために、ULIDは実質的にUUID形式に変換された後に、PostgreSQLのUUID fieldに保存されています。直接にULID文字列で、実質的UUID fieldとして保存されたデータを検索できるようにするには、少し工夫する必要があります。つまり、文字列での比較よりは、ulidのインスタンスで比較させたら、上手く行けます。
要注意なのは Django Admin 側で ModelAdmin.search_fields の挙動をカスタマイズすることはDRFの filters.SearchFilter には影響しません。そこで、Django Admin も DRF もそれぞれにカスタマイズする必要があります。
Django Admin 側の解決策
Django Adminの管理画面からULID領域を検索できるようにするために、少し admin.ModelAdmin を継承したクラスを工夫する必要があります。方法としては ModelAdmin.get_search_results() をoverrideすることで、 Django Admin の ModelAdmin.search_fields の挙動をカスタマイズすることができます。
実現したいビジネスロジックは実にはかなり簡単で、もし文字列はULIDの場合、ULIDField として定義された領域に対する専用のクエリを追加することだけです。
Django App 側の解決策
前述の通りに、ULID文字列は実質的にUUID文字列としてPostgreSQLに保存されたことは、ULID文字列を使ってULIDFieldを検索できない原因だと分かりました。
そうすると、この問題を解決するために、少なくとも2つ選択肢があります。
案Aは、serializer で ULIDField に対して、UUID 形式に変換してからAPIのレスポンスに渡すこと。こうすると、UUID文字列でデータベースにあるUUID文字列をクエリするのは、自然に検索できるようになります。ただし、厄介なのはUUIDではハイフンあったりなかったりする問題があるので、ハイフンなしのUUID文字列 017FE0937ADF9932AAAA01FD0BA14D3F では、ハイフンありのUUID文字列 017FE093-7ADF-9932-AAAA-01FD0BA14D3F を検索できないので、その逆も同然で、とても紛らわしく、開発者にも、ユーザーにも、さらに困惑させてしまう側面もあります。
案Bは、DRF の filters.SearchFilter をカスタマイズすることです。つまり既存の5個の lookup_prefixes 方法以外に、ULID検索専用の方法 iequal を追加すること。これによって、もともとデフォルトで icontains などで検索できないULIDFieldは、 インスタンス一致する検索方法の実現によって ULID文字列はそのまま利用しても検索できるようになります。
Prefix |
Lookup |
説明 |
---|---|---|
None |
icontains |
部分一致 (case insensitive) |
^ |
istartswith |
前方一致 (case insensitive) |
= |
iexact |
完全一致 (case insensitive) |
@ |
search |
全文検索 (case sensitive, PostgreSQLのみ) |
$ |
iregex |
正規表現一致 (case insensitive) |
# |
iequal |
インスタンス一致 (case insensitive) ← 新規実装 |
使い方として、もし search_field を以下のように定義しました。
実際のDjango querysetは以下のようにインスタンスの比較になり、ULIDFieldは検索できるようになります。
DRF の filters.SearchFilter をカスタマイズするには、construct_search と filter_queryset をoverrideする必要があります。処理方法は Django Admin 側の時とは実質的同じです。ここでは実装方法の例のイメージは以下のようです。
そこで、ulidカラムのULIDFieldの値をわざわざUUIDに変換してからの応用は、とても紛らわしくて使いにくいです。最終的に、2番目の案BのDRFのfilters.SearchFilterをカスタマイズする案を採用しました。
インデックス疑問
前述の通り、もしPostgreSQLを利用して、django-ulidライブラリのULIDFieldと言った組み合わせを利用した場合、ULIDは実質的にUUID形式に変換された後にPostgreSQLのUUID fieldに保存されています。すると、UUIDに変換されたULIDは、まだ単調増加性を保つことができるかと疑問が湧くかもしれません。もし単調増加性が失ったら、B+treeにおけるパフォーマンスも落ちるではとも思うのかもしれません。
それは、心配不要です。なぜなら、基礎編で説明したように、ULIDはUUIDに変換された後にも、構造的に変わらず、ただエンコード方式が改変されたため、Base32でなく、Hex方式でエンコードされたULIDも、ちゃんと単調増加性を維持できています。
ただ、注意点として、厳格的な単調性が必要な場合、例えば、Pythonのahawker版のulidライブラリを利用している場合は、必ず単調性保証付きのfrom ulid import monotonic as ulid のように専用のmonotonicライブラリからimport ulidしてください。もし普通にimport ulid で利用した場合、1ミリ秒以内に、1個以上のULIDを生成しない場合は、特に問題ありません。一方、そうでない場合は、劇的にパフォーマンスが低下するわけでもないですが、完全に単調増加性を確保している整数型自動採番やULIDと比べて、少々パフォーマンスが落ちるかもしれません。
マイグレーション問題
なんだと!Migration は無限ループになった?!
これで、ようやくすべての問題が解決されるだろうと思いきや、甘かったです。すぐに、また新しい問題が湧いてきました。
ULIDField の引数 default に ulid.new を渡したら、makemigration 命令で migrationファイルを作成して、migrate命令を実行しても、次から次に、毎回毎回また同じ内容のmigrationファイルは無限に再作成されてしまうことになりました。
もしかしたらDjangoのmigration処理のバグかなと思ってましたが、Django Project の Issues Ticket #32689 によると、とくにバグではないようでした。
I don't think there is anything we can improve in Django. new is a different object each time it's imported, so Django properly detects this as change.
一方、解決方法も実にはとても簡単で、ulid.new をシリアライズして文字列化にしてから関数値としてULIDFieldの引数のdefaultに渡せれば即時解決されました。
古いデータのulid領域もULIDが生成されてしまう問題
前述のように、INVOYでは整数型IDとULIDを共存させる方針にしました。一方、既存のユーザがすでに作った古いデータには、現状維持で、整数型IDしか持たせないことにしました。ここには、もう一つの陥りやすい罠が存在します。
もしulid領域をいきなり以下のように定義したら、makemigration命令で自動に作成されたmigrationファイルでは必ず古いデータすべてを含めて ULID が自動作成されてしまいます。
そこで、まずはdefault値指定なしで一度 makemigration 命令を実行します。
次に、正式的にdefault値を指定して、再度 makemigration 命令を実行します。
こうすると、作成された2つのmigrationファイルの二段階データ移行手順によって、既存データのulid領域も自動作成される問題を成功に回避できました。
もちろん、直接に最初からdefault値指定で獲得したmigrationファイルを手動で AddField と AlterField に2つのステップを分解するのも特に問題ありません。
上記問題を誘致した原因はおそらく、もしdefault値指定した場合、Django のmigration処理プロセスにおいて AddField と AlterField の挙動の違いだと思います。
また、直接には関係ないですが、もしPostgreSQL 11以前のバージョンを利用した場合、null=True を指定しないと、毎回毎回新しいカラムの追加につれてテーブルは丸ごとに書き直されるので、とても効率的に悪いので、要注意です。
The only caveat is that prior to PostgreSQL 11, adding columns with default values causes a full rewrite of the table, for a time proportional to its size. For this reason, it’s recommended you always create new columns with null=True, as this way they will be added immediately.
なんで?Migrationで生成されたULIDはすべて同じ?!
前述のmigration無限ループ問題をulid.newを文字列化にする関数を定義し、ULIDFieldのdefault引数に渡すことで、一応解決されました。しかし、これによって、また新しい問題が生じました。
もし古いデータにも、ulid領域にULIDを生成させたい場合、unique=True が指定されたので、migrate 命令を実行する際に、ULIDの値はuniqueでないために、unique constraint violationによってmigrationの実行は失敗になります。調査してみたところ、なぜか、Djangoのmigration処理のプロセスは、モデルに定義された ulid_new 関数を固定値として扱われたようです。
解決策として、手動でデータ移行関数を作ったら、無事にunique constraint violation問題を回避できました。
外部システムとの連携問題
BigQueryとのUDFでの融合問題
INVOYのデータ分析はBigQueryにまかせています。
しかし、前述したように、ULIDは実質的PostgreSQLにはUUID文字列として保存されていることにより、データの分析にULIDから特定できない支障が生じてしまいました。
幸いなのは、BigQueryではJavascriptベースの UDF (User Defined Function) 機能があり、そこで、例えばこちらの既存のJavascript版のULID/UUID変換ライブラリを利用してUUIDからULIDへ変換する関数を作れば、BigQueryにも ULID 文字列でデータを特定できるようになります。
やり方にもよりますが、毎回毎回リアルタイムで変換するのではなく、データ分析の前処理の ETL (Extract/Transform/Load) の段階で事前に ulid カラムを新設して、そこに UUID から変換された ULID を保存しておくのはおすすめです。
まとめ
以上は工程編の内容です。主にPythonにおいてULIDの実応用とか、実際のDjango/DRF工程において起こった様々な問題について話しました。最後のの実戦編ではどのようにニーズに応じて最適な自己流IDを設計するかについて詳しく述べます。
この記事、もしご参考になればとてもうれしいです。OLTAでは Tech Vision の元、一緒にユーザーに価値を提供し、その結果事業を成長させるサービス作り続けるための仲間を募集しています。 もし、この投稿にご興味を持っていただいたら、是非カジュアルにお話しさせてください。