django-fobiのformでアップロードしたファイルをMinioに保存する

# はじめに

必要に迫られて、django-fobiをForm Designerとして利用しています。

回答締め切り期限にフォームへのアクセスが集中しそうなので、負荷分散できるようにORM(Object Relational Mapping)の接続先をMySQLクラスターにして、BLOBファイルの保存にはAWS S3互換のオブジェクトストレージであるMinioを利用することにしました。

MySQLクラスターについては、ほぼ問題なく、そのまま接続先の情報を変更するだけで完了します。

Minioについては、/mediaや/staticの利用を全て切り替えようとすると大変になりますが、/staticは各Dockerコンテナ内に保存した情報を使う事にして、/mediaは利用しないと諦めれば、各アプリケーション毎に対応すれば良いことになります。

逆に/media, /staticのデフォルトストレージをMinioにしたい場合には、django-minio-backendなどのライブラリを利用するのが良さそうですが、少し試した印象ではアプリケーション毎の利用スタイルによって検討しなければいけないことがありそうで時間が必要と感じました。

今回はdjango-fobiのみを対象にして、アップロードしたファイルをMinioに保存する方法だけを検討しようと思います。

# 参考資料

* [https://django-fobi.readthedocs.io/en/latest/](https://django-fobi.readthedocs.io/en/latest/)
* [https://github.com/minio/minio-py/tree/release/examples](https://github.com/minio/minio-py/tree/release/examples) 

# django-fobiでMinioを利用するための方針を検討する

いくつかの方法が考えられます。

1. django用minio対応ライブラリを利用する
2. django-fobi用のhandlerプラグインを開発する (db_store handlerの改造)
3. django-fobi用のelementプラグインを開発する (File elementの改造)

それぞれ検討しています。

## django用のminioライブラリを利用する

最初に書いたように、django用のminioライブラリを試してみましたが、これはうまく期待したように簡単には動きませんでした。
djangoの中では/mediaへのアクセスはいくらか標準化されていますが、Storageクラス(django.core.files.storage.Storage)を使わなければいけないと強制されているわけでもないですし、これが簡単にFileSystemStorageクラスと置き換えられるのかというと、変更内容は軽微ですが、影響範囲が大きいので、検証しなければいけない範囲も大きくなり、少し手に負えないかなという印象でした。

しかし、人手があったり、予算と時間のあるフォーマルなプロジェクトであれば、この方法を検討するのが良いでしょう。

## Handlerプラグインの開発を検討する

2番目のhandlerプラグインを開発する方法は、目的からすると間違ったアプローチだと少ししてから気がつきました。

django-fobiのhandlerプラグインの動作は、各elementでの処理が終った結果をオブジェクトとして受け取るところから始まります。このためアップロードされたファイルは既に/mediaに配置されてしまっています。

## Elementプラグインの開発を検討する

最終的にはこの方法に落ち着きました。
venv/*/lib/python3.9/site-packages/fobi/contrib/plugins/form_elements/fields/file/ ディレクトリ全体をコピーして、必要な機能を持つminio_formエレメントを作成することにしました。

### デフォルトのfileエレメントでのファイルデータの保存方法

```python:venv/*/lib/python3.9/site-packages/fobi/contrib/plugins/form_elements/fields/file/base.pyからの抜粋
...
from fobi.helpers import handle_uploaded_file
...
__title__ = 'fobi.contrib.plugins.form_elements.fields.file.base'
__author__ = 'Artur Barseghyan <artur.barseghyan@gmail.com>'
__copyright__ = '2014-2019 Artur Barseghyan'
__license__ = 'GPL 2.0/LGPL 2.1'
__all__ = ('FileInputPlugin',)

class FileInputPlugin(FormFieldPlugin):
    """File field plugin."""
...
    def prepare_plugin_form_data(self, cleaned_data):
        """Prepare plugin form data.
        ...
        """
        # Get the file path
        file_path = cleaned_data.get(self.data.name, None)
        if file_path:
            # Handle the upload
            saved_file = handle_uploaded_file(FILES_UPLOAD_DIR, file_path)
            ...
```

最終的にはhandle_uploaded_file()によってファイル保存操作が完了します。
ただこのコードの実体は、fobi/helpers.py に格納されているため、このhelpers.pyファイルもコピーします。

今回は主に、この helpers.py ファイルを変更することになります。

# minio_fileエレメントの開発

まずはプロジェクト内の適当な場所にfileエレメントのファイルをコピーしてきます。

```bash:
    $ mkdir -p myapp/fobi_plugins/elements
$ cp -r venv/*/lib/python3.9/site-packages/fobi/contrib/plugins/form_elements/fields/file myapp/fobi_plugins/elements/minio_file
$ cp venv/*/lib/python3.9/site-packages/fobi/helpers.py myapp/fobi_plugins/elements/minio_file/
```

コピーが終った段階で、全体のディレクトリ構造は次のようになっています。

```text:djangoプロジェクトのディレクトリ構造
.
├── Dockerfile
├── Makefile
├── manage.py
├── myapp
│   ├── asgi.py
    ...
│   ├── fobi_plugins
│   │   └── elements
│   │       └── minio_file
│   │           ├── apps.py
│   │           ├── base.py
│   │           ├── conf.py
│   │           ├── defaults.py
│   │           ├── fields.py
│   │           ├── fobi_form_elements.py
│   │           ├── forms.py
│   │           ├── helpers.py
│   │           ├── __init__.py
│   │           └── settings.py
```

ある程度、作業が進んでから myapp/settings.py を変更していきます。

## 各ファイルの基本的な変更

変更が終った base.py と helpers.py 以外のファイルについて、差分は以下のようになっています。
名称やパスを変更に合わせただけです。

```diff:
diff -ur venv/myapp/lib/python3.9/site-packages/fobi/contrib/plugins/form_elements/fields/file/./apps.py myapp/fobi_plugins/elements/minio_file/./apps.py
--- venv/myapp/lib/python3.9/site-packages/fobi/contrib/plugins/form_elements/fields/file/./apps.py	2022-08-05 21:50:03.485391378 +0900
+++ myapp/fobi_plugins/elements/minio_file/./apps.py	2022-08-17 21:59:26.193981052 +0900
@@ -1,14 +1,13 @@
-class Config(AppConfig):
-    name = 'fobi.contrib.plugins.form_elements.fields.file'
-    label = 'fobi_contrib_plugins_form_elements_fields_file'
+class MinioFileConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'myapp.fobi_plugins.elements.minio_file' 

diff -ur venv/myapp/lib/python3.9/site-packages/fobi/contrib/plugins/form_elements/fields/file/./fobi_form_elements.py myapp/fobi_plugins/elements/minio_file/./fobi_form_elements.py
--- venv/myapp/lib/python3.9/site-packages/fobi/contrib/plugins/form_elements/fields/file/./fobi_form_elements.py	2022-08-05 21:50:03.485391378 +0900
+++ myapp/fobi_plugins/elements/minio_file/./fobi_form_elements.py	2022-08-17 23:26:28.863106266 +0900
@@ -1,14 +1,16 @@
-from .base import FileInputPlugin
-form_element_plugin_registry.register(FileInputPlugin)
+from .base import MinioInputPlugin
+form_element_plugin_registry.register(MinioInputPlugin)

diff -ur venv/myapp/lib/python3.9/site-packages/fobi/contrib/plugins/form_elements/fields/file/./__init__.py myapp/fobi_plugins/elements/minio_file/./__init__.py
--- venv/myapp/lib/python3.9/site-packages/fobi/contrib/plugins/form_elements/fields/file/./__init__.py	2022-08-05 21:50:03.485391378 +0900
+++ myapp/fobi_plugins/elements/minio_file/./__init__.py	2022-08-17 23:19:43.187465122 +0900
@@ -1,10 +1,11 @@ 
-UID = 'file'
+default_app_config = 'myapp.fobi_plugins.elements.minio_file.apps.MinioFileConfig'
+
+UID = 'minio_file'
```

次に myapp/settings.py を変更します。

```python:settings.pyへのプラグインの登録
INSTALLED_APPS = [
    ...
    'myapp.fobi_plugins.elements.minio_file',
    ...
]
```

default_app_configを設定しているので、直接MinioFileConfigを指定していません。


またMinioに接続するために必要な変数を定義しておきます。

```python:settings.py
MINIO_ENDPOINT = env.str('MINIO_ENDPOINT', "")
MINIO_ACCESS_KEY = env.str('MINIO_ACCESS_KEY', "")
MINIO_SECRET_KEY = env.str('MINIO_SECRET_KEY', "")
MINIO_FOBIFILE_BUCKET = env.str('MINIO_FOBIFILE_BUCKET', "")
MINIO_SECURE_CONN = env.bool("MINIO_SECURE_CONN", False)
```

名前のとおり接続に必要なENDPOINTの情報を"IP:Port"形式で与えたり、接続に必要なToken情報を与えるために使用します。
使い方はコードをみてもらえばすぐに分かるでしょう。

## minio_file/base.py の変更

変更点はほとんどありませんが、適切なhelpers.pyをインポートするようにしている点と、保存先を指定する必要がないので、適切なprefixだけを与えてあげます。

```diff:minio_file/base.py
--- venv/myapp/lib/python3.9/site-packages/fobi/contrib/plugins/form_elements/fields/file/base.py	2022-08-05 21:50:03.485391378 +0900
+++ myapp/fobi_plugins/elements/minio_file/base.py	2022-08-22 12:34:32.277474578 +0900
@@ -8,26 +15,19 @@
 from django.utils.translation import gettext_lazy as _
 
 from fobi.base import FormFieldPlugin
-from fobi.helpers import handle_uploaded_file
+from .helpers import handle_uploaded_file
 
 from . import UID
 from .fields import AllowedExtensionsFileField as FileField
 from .forms import FileInputForm
 from .settings import FILES_UPLOAD_DIR
 
-class FileInputPlugin(FormFieldPlugin):
+class MinioInputPlugin(FormFieldPlugin):
     """File field plugin."""
 
     uid = UID
-    name = _("File")
-    group = _("Fields")
+    name = _("Minio File")
+    group = _("Custom")
     form = FileInputForm
 
     def get_form_field_instances(self, request=None, form_entry=None,
@@ -60,20 +63,23 @@
         :return:
         """
         # Get the file path
-        file_path = cleaned_data.get(self.data.name, None)
-        if file_path:
+        file_obj = cleaned_data.get(self.data.name, None)
+        if file_obj:
             # Handle the upload
-            saved_file = handle_uploaded_file(FILES_UPLOAD_DIR, file_path)
+            saved_file = handle_uploaded_file(str(self.request.user), file_obj)
             # Overwrite ``cleaned_data`` of the ``form`` with path to moved
             # file.
             file_relative_url = saved_file.replace(os.path.sep, '/')
             cleaned_data[self.data.name] = "{0}{1}".format(
-                settings.MEDIA_URL,
-                file_relative_url
+                "minio:///",
+                file_relative_url,
             )
             # It's critically important to return the ``form`` with updated
             # ``cleaned_data``
             return cleaned_data
+        pass
 
     def submit_plugin_form_data(self,
                                 form_entry,
```

## minio_file/helpers.py の作成

base.pyから呼び出しているhandle_uploaded_file()の中身は次のようになっています。

```python:minio_file/helpers.py
## Original Information
## __title__ = 'fobi.helpers'                    
## __author__ = 'Artur Barseghyan <artur.barseghyan@gmail.com>'
## __copyright__ = '2014-2019 Artur Barseghyan'
## __license__ = 'GPL 2.0/LGPL 2.1'    

from django.conf import settings
from minio import Minio

import io
import os
import uuid
import hashlib

## for the 'image_file' instance check
from django.core.files.base import File

def handle_uploaded_file(identifier, image_file):
    client = Minio(settings.MINIO_ENDPOINT,
                   access_key=settings.MINIO_ACCESS_KEY,
                   secret_key=settings.MINIO_SECRET_KEY,
                   secure=settings.MINIO_SECURE_CONN,)
    if isinstance(image_file, File):
        destination_path = os.path.join("/", str(identifier), uuid.uuid4().hex, image_file.name)
        try:
            image_file_data = image_file.read()
            sha256_msg = hashlib.sha256(image_file_data).hexdigest()
            client.put_object(settings.MINIO_FOBIFILE_BUCKET, destination_path,
                              io.BytesIO(image_file_data), length=len(image_file_data),
                              metadata={ 'sha256_digest': sha256_msg }, )
        except ResponseError as err:
            print("response error")
            print(err)
            pass
        return destination_path
    else:
        print("image_file is not instance of File class")
        pass
    return image_file
```

このコードはあまり効率を考えておらず、SHA256ハッシュを求めるためにメモリにコピー(image_file.read())したデータをそのまま処理しているので、アップロードするファイルのサイズによっては

# 【閑話休題】djangoにおけるファイルアップロードの最大サイズ

djangoアプリを使うために、Reverse Proxyサーバーを外部ネットワークとの境界に配置する場合には、djangoアプリ以外にも考慮点が発生します。

## Djangoアプリケーション上での設定

djangoアプリケーションでは、settings.py でアップロードするファイルサイズを指定します。

```python:settings.pyからの抜粋

## 最大ファイルサイズを256MiB(256*1024*1024)に設定
DATA_UPLOAD_MAX_MEMORY_SIZE = 268435456

```

## nginx上での設定

client_max_body_sizeで指定します。
この指定を*0*にすると無制限にできますが、無制限はworkerプロセスの処理時間を束縛するだけではなくて、メモリも消費するのでお勧めしません。

djangoアプリケーション専用でなければ、デフォルトは1m程度にしておき、必要に応じて拡張するようにしてください。

```nginx:server.conf
server { 

    client_max_body_size 1m;

    location /myapp/ {
        client_max_body_size 256m;
    }

}
```

## Kuberentes(k8s)のingress-nginxを経由している場合

フロントエンドがnginxで直接設定ファイルを編集できる場合は良いのですが、バックエンドがingress(k8s)である場合には直接設定ファイルを編集することができません。

Ingressオブジェクトを定義するYAMLファイルのannotationsに次のような設定を加えます。

```yaml:ingress.yamlファイルから抜粋
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  ...
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "256m"
```

# さいごに

2005年頃にBPELを扱っていた経験もあって、ワークフロー周りの技術はニーズはあるものの鬼門だなぁ、と感じています。

帳票はビジネスが他者との関係性において成立するものである以上、どんなビジネス環境でも確実にニーズが存在します。

一方で利用方法は文化的な影響を強く受けるため、汎用的に利用できるフレームワークを実装することは難しいものです。
このため、汎用性を備えた帳票ソリューションは、かなり複雑になって、障害対応なんて悪夢以外のなにものでもありません。

django-fobiはワークフローがなくて、単にフォームを設計して、提出されたフォームを保存するだけのプラットフォームですが、設計されたフォームも、保存されたデータもORMを通じてアクセスできる点で、かなり便利だなという印象です。

ただORMは様々に考慮されたSQLが自動的に生成される反面、トラブルが発生するとデバッグのために複雑なSQLを広範囲に確認しなければいけなくなる点で、Ruby on Railsなども同様ですが、Docker/Kubernetesの環境で動作させるには少し躊躇してしまいます。

かなりシンプルな利用方法であれば、django-fobiはお勧めできるかなと思います。

以上