OLTA TECH BLOG

テクノロジーと好奇心で事業を成長させる

TECH BLOG

ULID移行から最適な自己流IDの設計まで・その4【工程編】:ULIDをDjango+PostgreSQLにおける移行工程の問題集

 

◇ アノキミモノガタリその肆

アノ日のULID、キミとまた出会えるまで十億億回の輪廻のタイムスリップ、A.D.10889年まで。

君の名は、01FZG96YPZK4SANAG1ZM5T2K9Z、忘れないその目。

リソースがないこのいま、タイムマシンは壊れた以上、過去に戻すことはもうできない。

イムループ自体もパラドックスになってしまった。

しかし、諦めたくない。諦めてはいけない。

キミは、きっと広大なネット上のどこかで僕とコマちゃんを待っているだろう。

広大なネット上で、きっと僕らの居場所もある。

はじめに

こんにちは、転生したら相変わらずエンジニアだった OLTA(オルタ)プロダクトグループ研究開発チームのタイトルの前置きがとにかく長い B.です。

INVOYカードの開発を担当しています。

今回、UUID*1RFCRFC4122からRFC9562に刷新されたことをきっかけに、約2年間眠っていたテックブログの記事を加筆して発表しようと思います。

選択編ではULID移行時に整数型IDと入れ替わるべきか?共存させるべきか?について話しました。工程編ではPythonにおいてULIDの実応用とか、実際のDjango/DRF工程において起こった様々な問題について詳しく述べます。

PythonにおけるULIDの実際応用

以下はPythonulid ライブラリを利用した前提の例としてULIDの利用時のイメージです。

>>> import ulid

# ULIDの生成と初期化
>>> my_ulid = ulid.new()
<ULID('01FZG96YPZK4SANAG1ZM5T2K9Z')>

# ULIDのそれぞれの形式の取得

# 文字列の取得
>>> my_ulid.str
'01FZG96YPZK4SANAG1ZM5T2K9Z'
>>> my_ulid.str.lower()
'01fzg96ypzk4sanag1zm5t2k9z'

# バイナリの取得
>>> my_ulid.bin
'0b1011111111110000010010011011110101101111110011001001100101010101010101010000000011111110100001011101000010100110100111111'

# 八進数の取得
>>> my_ulid.oct
'0o13776022336557631145252520037641350246477'

# floatの取得
>>> my_ulid.float
1.9932046411374316e+36

# 整数型の取得
>>> my_ulid.int
1993204641137431665444657879740140863

# ULIDのタイムスタンプ部分の取得
>>> my_ulid.timestamp()
<Timestamp('01FZG96YPZ')>

# ULIDのランダム値部分の取得
>>> my_ulid.randomness()
<Randomness('K4SANAG1ZM5T2K9Z')>

もっと安全な乱数生成方式も指定できます。

>>> import secrets

# os.urandom() より暗号学的に機密レベルの強い安全な乱数の生成
>>> randomness = secrets.token_bytes(10)
b'\xc2\xbe\x0f{\xab\xb6V\x81T\xf6'

>>> ulid.from_randomness(randomness)
<ULID('01FZG96YPZ9MVZE7RTF8H762Q5')>

前述の通り、ULIDは実にはバージョン情報なしのUUIDへも変換できます。

# ULIDからUUIDへの変換
>>> my_uuid = my_ulid.uuid
UUID('017fe093-7adf-9932-aaaa-01fd0ba14d3f')

# UUIDのハイフンありhex値文字列の取得
>>> str(my_uuid)
'017fe093-7adf-9932-aaaa-01fd0ba14d3f'
>>> str(my_uuid).upper()
'017FE093-7ADF-9932-AAAA-01FD0BA14D3F'

# UUIDのハイフンなしのhex値文字列の取得
>>> my_uuid.hex
'017fe0937adf9932aaaa01fd0ba14d3f'
>>> my_uuid.hex.upper()
'017FE0937ADF9932AAAA01FD0BA14D3F'

# UUIDの各部分の取得
>>> my_uuid.time_low
25157779
>>> my_uuid.time_mid
31455
>>> my_uuid.time_hi_version
39218
>>> my_uuid.clock_seq
10922
>>> my_uuid.clock_seq_low
170
>>> my_uuid.clock_seq_hi_variant
170
>>> my_uuid.node
2186333474111
>>> my_uuid.fields
(25157779, 31455, 39218, 170, 170, 2186333474111)

# UUIDバージョン情報とバリエーション情報の取得
# バージョンとバリエーションは実質的デタラメになります
>>> my_uuid.version
9
>>> my_uuid.variant
'specified in RFC 4122'

もちろん、逆にUUIDからもULIDへ変換できます。

また、本物のUUID v4の場合と比較してみれば、なぜはULIDベースのUUIDのバージョン情報はデタラメだとすぐに分かると思います。

>>> import uuid

>>> my_uuid4 = uuid.uuid4()
UUID('b43a28cb-a649-426d-ad59-13b2a02894e3')
>>> my_ulid4 = ulid.from_uuid(my_uuid4)
<ULID('5M78MCQ9J989PTTP8KPAG2H573')>

# 本物のUUID v4の場合、ちゃんと正しいバージョン情報とバリエーション情報が表示されます
>>> my_ulid4.uuid.version
4
>>> my_ulid4.uuid.variant
'specified in RFC 4122'

マイクロ秒まで単調増加できるULID

おまけとして、実には Python の ahawker 版の ulid ライブラリは、マイクロ秒までの精度で単調増加できる機能も実現できました。

ただし、要注意なのはこの機能において、最後の80-bitの最初の10-bitはマイクロ秒用に使われたので、精度はミリ秒からマイクロ秒までに上がった代わりに、同じマイクロ秒で作成されたULIDのp=0.5時にける衝突の試行回数の期待値は343億回まで下がってしまいます。

# 2^(70/2)≒3.4e+10
>>> math.pow(2, 35)
>>> 34359738368

また残念ですが、こちらは ULID の正式的な仕様ではありません。そこで、この機能の利用には、上記のデメリットを承知した上で、すべて自己責任になります。

>>> ulid.microsecond.new()
<ULID('01FZG96YPZ0BKJHF0370TNGQ4Z')>

ULIDの移行工程で起きた疑問や問題集

クエリの検索問題

なんと、ULIDってクエリで検索できないの?!

ULIDを採用した後に、特にDjangoDjango REST Framework (DRF) 、PostgreSQLdjango-ulidライブラリのULIDFieldと言った組み合わせを利用した場合、とても陥りやすい罠が存在しています。それは、もし安直にDRFfilters.SearchFilterを利用したら、ULID領域は予想通りに検索できないことです。

検索できない理由

class ULIDField(models.Field):
    """
    Django model field type for handling ULID's.
    This field type is natively stored in the DB as a UUID (when supported) and a string/varchar otherwise.
    """

上記引用のように django-ulid/models.py によると、もし利用されているデータベース管理システムはUUIDがサポートされている場合、ULID文字列の代わりに、変換後のversion情報なしのUUID形式でデータベースに保存されることになります。

そこで、もしDRFfilters.SearchFilterを直接に利用したら、 ULID文字列で、実質的UUID文字列として保存されたデータをクエリしたら、検索できるわけがありません。

ULIDを検索できるようにするための突破口

まずは知っておく必要があるのは、DRFfilters.SearchFilter は実質的にDjango Admin の ModelAdmin.search_fields を利用していることです。

DjangoModelAdmin.search_fieldsにおいて、5個のデフォルトの検索方法が提供されました。

Prefix

Lookup

説明

None

icontains

部分一致 (case insensitive)

^

startswith

前方一致 (case sensitive)

=

iexact

完全一致 (case insensitive)

@

search

全文検索 (case sensitive, PostgreSQLのみ)

一方、DRFfilters.SearchFilter は以下のようにカスタマイズされて、startswithistartswithに変更され、さらに正規表現用のiregex方法は追加されました。

Prefix

Lookup

説明

None

icontains

部分一致 (case insensitive)

^

startswith

前方一致 (case insensitive)

=

iexact

完全一致 (case insensitive)

@

search

全文検索 (case sensitive, PostgreSQLのみ)

$

iregex

正規表現一致 (case insensitive)

使い方として、もし search_field を以下のように定義しました。

search_fields = ('=id', 'name', '^company', '@comment', '$status')

実際のDjango querysetはそれぞれに以下のようになります。

MyModel.objects.filter(id__iexact=search_term)
MyModel.objects.filter(name__icontains=search_term)
MyModel.objects.filter(company__istartswitht=search_term)
MyModel.objects.filter(comment__search=search_term)
MyModel.objects.filter(status__iregex=search_term)

PostgreSQLではUUIDに対して専用なUUID Data Typeがあるために、ULIDは実質的にUUID形式に変換された後に、PostgreSQLのUUID fieldに保存されています。直接にULID文字列で、実質的UUID fieldとして保存されたデータを検索できるようにするには、少し工夫する必要があります。つまり、文字列での比較よりは、ulidのインスタンスで比較させたら、上手く行けます。

# インスタンスで比較させるイメージ
ulid_instance = ulid.parse(ulid_str)
MyModel.objects.filter(id=ulid_instance)

要注意なのは Django Admin 側で ModelAdmin.search_fields の挙動をカスタマイズすることはDRFfilters.SearchFilter には影響しません。そこで、Django Admin も DRF もそれぞれにカスタマイズする必要があります。

Django Admin 側の解決策

Django Adminの管理画面からULID領域を検索できるようにするために、少し admin.ModelAdmin を継承したクラスを工夫する必要があります。方法としては ModelAdmin.get_search_results()overrideすることで、 Django Admin の ModelAdmin.search_fields の挙動をカスタマイズすることができます。

実現したいビジネスロジックは実にはかなり簡単で、もし文字列はULIDの場合、ULIDField として定義された領域に対する専用のクエリを追加することだけです。

import ulid

class MyModelAdmin(admin.ModelAdmin):
    search_fields = ("id",)

    def get_search_results(self, request, queryset, search_term):
        queryset, use_distinct = super(MyModelAdminName, self).get_search_results(request, queryset, search_term)
        try:
            search_ulid = ulid.parse(search_term)
            queryset |= self.model.objects.filter(id=search_ulid)
        except ValueError:
            pass
        return queryset, use_distinct

Django App 側の解決策

前述の通りに、ULID文字列は実質的にUUID文字列としてPostgreSQLに保存されたことは、ULID文字列を使ってULIDFieldを検索できない原因だと分かりました。

そうすると、この問題を解決するために、少なくとも2つ選択肢があります。

案Aは、serializer で ULIDField に対して、UUID 形式に変換してからAPIのレスポンスに渡すこと。こうすると、UUID文字列でデータベースにあるUUID文字列をクエリするのは、自然に検索できるようになります。ただし、厄介なのはUUIDではハイフンあったりなかったりする問題があるので、ハイフンなしのUUID文字列 017FE0937ADF9932AAAA01FD0BA14D3F では、ハイフンありのUUID文字列 017FE093-7ADF-9932-AAAA-01FD0BA14D3F を検索できないので、その逆も同然で、とても紛らわしく、開発者にも、ユーザーにも、さらに困惑させてしまう側面もあります。

class MyModelSerializer(ModelSerializer):
    id = serializers.SerializerMethodField(read_only=True)

    def get_id(self, instance):
        return instance.id.uuid

案Bは、DRFfilters.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 を以下のように定義しました。

search_fields = ('#id')

実際のDjango querysetは以下のようにインスタンスの比較になり、ULIDFieldは検索できるようになります。

MyModel.objects.filter(id=search_term)

DRFfilters.SearchFilter をカスタマイズするには、construct_searchfilter_querysetoverrideする必要があります。処理方法は Django Admin 側の時とは実質的同じです。ここでは実装方法の例のイメージは以下のようです。

def construct_search(self, field_name):
    lookup = self.lookup_prefixes.get(field_name[0])
    if lookup == 'iequal':
        return field_name
    if lookup:
        field_name = field_name[1:]
    else:
        lookup = 'icontains'
    return LOOKUP_SEP.join([field_name, lookup])
    
def filter_queryset(self, request, queryset, view):
    search_fields = self.get_search_fields(view, request)
    search_terms = self.get_search_terms(request)

    if not search_fields or not search_terms:
        return queryset

    orm_lookups = [self.construct_search(str(search_field)) for search_field in search_fields]

    base = queryset
    conditions = []
    queries = []
    for search_term in search_terms:
        try:
            if type(search_term) != str:
                raise ValueError
            ulid.parse(search_term)
            queries = [models.Q(**{orm_lookup.lstrip('#'): search_term}) for orm_lookup in orm_lookups]
        except ValueError:
            for orm_lookup in orm_lookups:
                if not orm_lookup.startswith('#'):
                    queries.append(models.Q(**{orm_lookup: search_term}))
        if queries:
            conditions.append(reduce(operator.or_, queries))

    if conditions:
        queryset = queryset.filter(reduce(operator.and_, conditions))
    else:
        queryset = queryset.none()

    if self.must_call_distinct(queryset, search_fields):
        queryset = distinct(queryset, base)

    return queryset

そこで、ulidカラムのULIDFieldの値をわざわざUUIDに変換してからの応用は、とても紛らわしくて使いにくいです。最終的に、2番目の案BのDRFfilters.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 の引数 defaultulid.new を渡したら、makemigration 命令で migrationファイルを作成して、migrate命令を実行しても、次から次に、毎回毎回また同じ内容のmigrationファイルは無限に再作成されてしまうことになりました。

migrations.AlterField(field=django_ulid.models.ULIDField(default=ulid.api.api.Api.new), ...)

もしかしたらDjangomigration処理のバグかなと思ってましたが、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に渡せれば即時解決されました。

def ulid_new():
    return str(ulid.new())

class MyModelName(models.Model):
    id = ULIDField(default=ulid_new, primary_key=True)

古いデータのulid領域もULIDが生成されてしまう問題

前述のように、INVOYでは整数型IDとULIDを共存させる方針にしました。一方、既存のユーザがすでに作った古いデータには、現状維持で、整数型IDしか持たせないことにしました。ここには、もう一つの陥りやすい罠が存在します。

もしulid領域をいきなり以下のように定義したら、makemigration命令で自動に作成されたmigrationファイルでは必ず古いデータすべてを含めて ULID が自動作成されてしまいます。

def ulid_new():
    return str(ulid.new())

class MyModelName(models.Model):
    ulid = ULIDField(default=ulid_new, null=True, unique=True)

そこで、まずはdefault値指定なしで一度 makemigration 命令を実行します。

class MyModelName(models.Model):
    ulid = ULIDField(null=True, unique=True)

次に、正式的にdefault値を指定して、再度 makemigration 命令を実行します。

def ulid_new():
    return str(ulid.new())

class MyModelName(models.Model):
    ulid = ULIDField(default=ulid_new, null=True, unique=True)

こうすると、作成された2つのmigrationファイルの二段階データ移行手順によって、既存データのulid領域も自動作成される問題を成功に回避できました。

もちろん、直接に最初からdefault値指定で獲得したmigrationファイルを手動で AddFieldAlterField に2つのステップを分解するのも特に問題ありません。

上記問題を誘致した原因はおそらく、もしdefault値指定した場合、Djangomigration処理プロセスにおいて AddFieldAlterField の挙動の違いだと思います。

また、直接には関係ないですが、もし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を文字列化にする関数を定義し、ULIDFielddefault引数に渡すことで、一応解決されました。しかし、これによって、また新しい問題が生じました。

もし古いデータにも、ulid領域にULIDを生成させたい場合、unique=True が指定されたので、migrate 命令を実行する際に、ULIDの値はuniqueでないために、unique constraint violationによってmigrationの実行は失敗になります。調査してみたところ、なぜか、Djangomigration処理のプロセスは、モデルに定義された ulid_new 関数を固定値として扱われたようです。

解決策として、手動でデータ移行関数を作ったら、無事にunique constraint violation問題を回避できました。

from django.db import migrations
from ulid.api import new as ulid_default

def forwards_func(apps, schema_editor):
    MyModelName = apps.get_model("MyAppName", "MyModelName")
    db_alias = schema_editor.connection.alias

    for item in MyModelName.objects.using(db_alias):
        item.ulid = item.ulid if item.ulid else ulid_default()
        item.save()

class Migration(migrations.Migration):
    ...
    operations = [
        migrations.RunPython(forwards_func),
    ]

外部システムとの連携問題

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 を保存しておくのはおすすめです。

const { Ulid } = require('id128');
module.exports = {
  uuidToUlid: (uuid) => {
    return Ulid.fromRaw(uuid.replace(/-/g, '')).toCanonical()
  }
}

まとめ

以上は工程編の内容です。主にPythonにおいてULIDの実応用とか、実際のDjango/DRF工程において起こった様々な問題について話しました。最後のの実戦編ではどのようにニーズに応じて最適な自己流IDを設計するかについて詳しく述べます。

この記事、もしご参考になればとてもうれしいです。OLTAでは Tech Vision の元、一緒にユーザーに価値を提供し、その結果事業を成長させるサービス作り続けるための仲間を募集しています。 もし、この投稿にご興味を持っていただいたら、是非カジュアルにお話しさせてください。

corp.olta.co.jp

IDシリーズ記事の一覧

techblog.olta.co.jp