[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

DjangoのFileFieldのバックエンドを動的に切り替えたい(S3BotoStorageの例)

DjangoのモデルにはFileFieldImageFieldなど、バイナリファイルを扱う属性があります。

これらに格納されるファイルの保存先を動的に変更する方法を確認しました。

FileFieldImageFieldの動き

これらのフィールドにはファイルを格納することができますが、ファイルがバイナリ化されてバックエンドのDBに格納されるわけではありません。パフォーマンスの観点からsettings.MEDIA_ROOTに指定されたディレクトリ配下にファイルを保存し、MEDIA_ROOTを起点とした相対パスの情報のみをDBに格納するように動きます。

なお、保存されるパスは各Fieldのupload_toも考慮されますので実際には以下のようなディレクトリになります。

MEDIA_ROOT/upload_to/file_name

通常はMEDIA_ROOTはローカルファイルパスを指しており、Djangoが稼働するノードのファイルシステム上に保存されます。

S3BotoStorageの場合

S3BotoStorageFileFieldなどで格納されるファイルをS3のBucket上に保存してくれるサードパーティライブラリです。保存先はsettings.AWS_STORAGE_BUCKET_NAMEで指定したバケットになり、MEDIA_ROOTは使用されなくなります(たしか…)

FileFieldの保存先が確定するタイミング

class FileField(Field):

    # The class to wrap instance attributes in. Accessing the file object off
    # the instance will always return an instance of attr_class.
    attr_class = FieldFile

    # The descriptor to use for accessing the attribute off of the class.
    descriptor_class = FileDescriptor

    description = _("File")

    def __init__(self, verbose_name=None, name=None, upload_to='', storage=None, **kwargs):
        self._primary_key_set_explicitly = 'primary_key' in kwargs

        self.storage = storage or default_storage
        self.upload_to = upload_to

        kwargs['max_length'] = kwargs.get('max_length', 100)
        super(FileField, self).__init__(verbose_name, name, **kwargs)

FileFieldのソースはこんな感じになっていて、格納先に関わるのはself.storageself.upload_toです。いずれもクラスが初期化された時点で確定します。storageを明示していない場合はsettingsで設定したものがdefault_storageとして利用されます。

FileFieldがどのようなバックエンドで(S3なのかローカルファイルシステムなのか)どのような場所(バケット名やディレクトリ名)を使用するかが確定し、settingsから読み込まれるのはdjango.setup()が実行されて該当のモデルが初期化されたタイミングです。(厳密には、初期化されてから最初にアクセスされたタイミングで確定しますが面倒なので無視します)

これがとういうことかは以下のS3BotoStorageを使用しているコードを例に説明します。モデルのcontentStorageを明示していないものとします。

from django.conf import settings


settings.AWS_STORAGE_BUCKET_NAME = 'bucket_1'

# 既存のモデルを取得
my_model = MyModel.objects.get(pk=1)

# デフォルトのバケットからファイルを取得
my_model.content.open()
data = my_model.content.read()
my_model.content.close()

# 移植用に仮想のファイルオブジェクトを生成
tmp_file = BytesIO(data)
tmp_file.seek(0)
tmp_file = ContentFile(tmp_file.getvalue())
tmp_file.name = my_model.filename

# バケット名を変更する
settings.AWS_STORAGE_BUCKET_NAME = 'bucket_2'

# 別のモデルに移植
my_other_model = MyModel(
    content=tmp_file,
)
my_other_model.save()

コードだけ見ればmy_other_model.contentbucket_2に保存されてほしいですが、残念ながらbucket_1に保存されます。bucket_2への変更をしてもすでにMyModel.contentstorageは確定しているので変更が読み込まれることはありません。

もちろん、移行先のModel定義自体が違うのであればFileField.storageを明示して他のバケットを書けばできますが、今回はやんごとなき事情で同じモデルのまま別のバケットに移行する必要があったのでこれはできませんでした。

ということでどうすればいいのかです。

from django.conf import settings

# 既存のモデルを取得
my_model = MyModel.objects.get(pk=1)

# これはデフォルトと同じなので書かなくてもいいけど、Storageを明示
my_model.content.storage = S3BotoStorage(bucket='bucket_1')

# デフォルトのバケットからファイルを取得
my_model.content.open()
data = my_model.content.read()
my_model.content.close()

# 移植用に仮想のファイルオブジェクトを生成
tmp_file = BytesIO(data)
tmp_file.seek(0)
tmp_file = ContentFile(tmp_file.getvalue())
tmp_file.name = my_model.filename


# 別のモデルに移植
my_other_model = MyModel(
    content=tmp_file,
)
# ここでStorageごと差し替える
my_other_model.content.storage = S3BotoStorage(bucket='bucket_2')
my_other_model.save()

結局、実ファイルへのアクセスが発生するタイミング(read,write)時点でのStorageをもとにファイルアクセスが行われるので、上述の例のようにFileField.storageをまるっと差し替えてやれば意図した結果を得ることができます。

まとめ

利用ケースがマニアック過ぎて誰の役にたつんだって内容をまとめました。FileFieldのデータをバックエンドのファイル含めて別に移行しなければいけなくなったケースくらいでしか生きない知識ですが、どこかで役に立つことを祈っています。