こちらの開発を続けています。
構成を簡単に書けば
├── jq-rally-api(Django REST framework:Heorku にリリース)
├── jq-rally-db
│ ├── mysql
├── jq-rally-web(Nuxt:Azure Static Web Apps にリリース)
という感じです。
その中で、ファイルアップロードの処理をしようとして、若干苦戦しました。
ファイルアップロード先について
こちらを参考にさせていただき、どれもさほど料金変わらないので、せっかく、Azure Static Web Apps の記事の続きということもあるので(全然関係ないですが)、Azure を使うことにします。
なお、Cloud Firestore だと、無料プランから始められるので良いかなと思いましたが、後述の django-storages が対応していないようで、ちょっと厳しいかなと思いました。
処理方式
まず、Django でファイルを受け取るところに関しては、ImageKit が良さそうです。
ファイルを保存する際に、サムネイルも作ってくれるというものです。
次に、クラウドストレージに保存するという点に関しては、django-storages が提供されています。これは、Azureに対応しているので安心です。
Nuxt側では、Vuetify を使っているので、v-file-input でOKです。結果的には苦戦しましたが、v-model でそのまま指定ファイルを Axios に渡すところまで持っていけるので、その部分の苦労はありませんでした。
先に簡単に触れておくと、
<v-file-input
:rules="rules"
accept="image/png, image/jpeg, image/bmp"
placeholder="画像選択"
prepend-icon="mdi-camera"
label="画像選択"
dense
/>
これを書いておくだけで、ファイルは、アップされました。すごい。
これをベースに、保存先を指定したり、データへの更新をしていきます。
とりあえず、Python 環境で、下記をインストールします。
pip install -U Pillow
pip install -U django-imagekit
pip install django-storages[azure]
pip3 freeze > requirements.txt
ImageKit
インストール後、settings.py の、INSTALLED_APPS に追記必要とのこと。
また、MEDIA_ROOT の名前でファイル保存場所を指定。
INSTALLED_APPS = [
・・・
'imagekit',
]
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/media/"
MEDIA_URL は、ローカル保存の状態では機能していませんでした。要調査ですが、最終的にはクラウドストレージ保存により参照できるようになったので、一旦保留としておきます。
そして、モデルで、ImageField の指定を行い、保存先を指定し、サムネイル作成の指定をします。
from imagekit.models import ImageSpecField, ProcessedImageField
from imagekit.processors import ResizeToFill
class UserSpotPhoto(models.Model):
・・・・
photo = models.ImageField(upload_to="photos/%y/%m/%d/", verbose_name='画像', null=True)
thumbnail = ImageSpecField(source='photo',
processors=[ResizeToFill(250,250)],
format="JPEG",
options={'quality': 60}
)
こう書くだけで、サムネイル作成されるはずかと思いましたが、作成されず。
アップロード後に下記を実施するとサムネイルも作られました。
python manage.py generateimages
自動で作成されないのはなぜか・・・
こちらを参照。
IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = 'imagekit.cachefiles.strategies.Optimistic'
これで作られるようになりました。
django-storages
Azureストレージ作成
まずは、Azureストレージ作成
金額を抑えるために冗長性はローカル冗長ストレージ (LRS)。
他は既定値。
アプリもAzure内に置くならば、ネットワーク接続は、本当は、プライベートネットワークを構成して限定できるのだろうか。
さらに、BLOB用のコンテナを作成。
django-storagesの設定
django-storages は、AWS S3の情報はいくつかありますが、Azureとしての情報は少ない気がします。
どこまで参考にしたら良いか恐る恐るやりましたが、ここは割と簡単。
基本的には、settings.py に、DEFAULT_FILE_STORAGE の記述を追加するだけで、機能します。
S3の場合は、
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage'
となりますが、今回はAzureなので
DEFAULT_FILE_STORAGE = 'storages.backends.azure_storage.AzureStorage'
さらに
AZURE_ACCOUNT_NAME = "xxx"
AZURE_ACCOUNT_KEY = "xxx"
AZURE_CONTAINER = "xxx"
で、接続して、ファイルを作り出します。すごい。
なお、
STATICFILES_STORAGE = 'storages.backends.azure_storage.AzureStorage'
は不要です。やってしまうと、CSSとかも無効になってしまいます。
適宜、非公開情報は、settings.py ではなく、local_settings.py に。
元は、MEDIA_ROOTで指定した os.path.join(BASE_DIR, "media") 、すなわち、jq-rally-api/media に、CACHE、photos が作られる構成でしたが、
これにより、AZURE_CONTAINER で指定した jqrally-image コンテナに、CACHE、photos が作られる構成に変わりました。
ただ、この状態だと
<Error>
<Code>ResourceNotFound</Code>
<Message>The specified resource does not exist. RequestId:10c4497b-f01e-008e-2ee0-8b9961000000 Time:2021-08-07T23:02:47.8606904Z</Message>
</Error>
というエラーになっています。アクセス制御の問題です。
アクセス制御について、いくつか方法があると思います。
- コンテナのアクセスレベルを変更
初期値=「プライベート(匿名アクセスはありません)」
これを
「BLOB(BLOB専用の匿名読み取りアクセス)」
とすると見えるようになります。
コンテナー内の BLOB は匿名要求によって読み取ることができますが、コンテナーのデータは使用できません。匿名クライアントはコンテナー内の BLOB を列挙できません。
という意味ですが、公開用の画像なので、これで良いのではと思います。
- SASの生成
公開範囲(期限、IPアドレスなど)を制限する場合は、これが正しいと思いますが、django-storagesでそこまではやってくれなそう。
ローカルにしたかったら、また、DEFAULT_FILE_STORAGE を削除すれば自動で切り替わる。素晴らしい。
なお、DBデータとしては「photos/21/08/07/daan.jpeg」として登録されています。なるほど。
Nuxtからアップ
ここまでは、Djangoの管理画面でファイルをアップして確認していたのですが、いよいよアプリからアップします。
最初に書いたように、Vuetify の、v-file-input を使います。
<v-file-input
v-model="form.photo"
:rules="rules"
accept="image/png, image/jpeg, image/bmp"
placeholder="画像選択"
prepend-icon="mdi-camera"
label="画像選択"
dense
/>
setup (props) {
const state = reactive({
snackbar: false,
rules: [
(value: { size: number }) => !value || value.size < 2000000 || 'Avatar size should be less than 2 MB!'
],
menu: false
})
const { createState, createData } = useUserSpotPhoto()
const handleAddPhoto = async () => {
try {
createState.user = defaultUserItem
createState.event = props.event
createState.spot = props.spot
const newData = await createData(createState)
if (!newData) {
return
}
state.snackbar = true
} catch (error) {
// console.log('error', error)
}
}
return {
form: createState,
...toRefs(state),
handleAddPhoto
}
formという名前でリアクティブになっているcreateStateをそのままuseUserSpotPhotoのcreateDataというオブジェクトに渡しています。
v-model="form.photo"
を書いておけば、ファイル選択した情報が流れていってくれるというのは非常に良いです。
ここから先は次のような形です。
const createData = async (payload: CreateUserSpotPhotoRequest) => {
try {
const response = await $repository.userSpotPhoto.create(payload)
if (response) {
await getList()
return response
}
} catch (error) {
// eslint-disable-next-line no-console
console.log('userSpotPhoto.create error', error)
}
return false
}
create (payload: CreateUserSpotPhotoRequest): Response | CustomErrors {
// eslint-disable-next-line no-console
// FormDataに詰め替え
const data = new FormData()
data.append('user_id', payload.user.id)
data.append('spot_id', payload.spot.id)
data.append('event_id', payload.event.id)
const dt = '' + payload.visit_date
data.append('visit_date', dt)
if (payload.photo) {
data.append('photo', payload.photo, payload.photo.name)
}
const config = {
headers: {
'content-type': 'multipart/form-data'
}
}
// return axios.$post(`/${res}/`, payload)
// eslint-disable-next-line no-console
console.log('data', data, data.getAll('user'))
return axios.$post(`/${res}/`, data, config)
.catch((error) => {
return error.response
})
},
コメントに残しているように、ファイルアップロードがないならば、引数payloadとして渡ってきたオブジェクトをそのままaxiosに渡して、API呼び出しができます。
ところが、ここにファイルが付くと、
「添付されたデータはファイルではありません。フォームのエンコーディングタイプを確認してください。」
というエラーが発生しました。
これについては、こちらが分かりやすかったです。
ということで、userSpotPhotoRepository.ts の中で、FormDataに詰め替えをしています。
「multipart/form-data」もここに書いておけば大丈夫でした。
ところが、これでも失敗します。画面で拾うエラーは「400 bad request」以上の詳細情報が得られず、ちょっと苦労しました。
まず、そもそもファイルなしでもエラーになっていました。
「The .create()
method does not support writable nested fields by default.」
WritableNestedModelSerializer という対応も試してみましたが、それはだめでした。
ここで、いったん、Postman でやってみたらどうかと思ってやってみると、こちらはエラーをはっきり出してくれました。もう少し早めにやればよかった・・・
「この項目は必須です」というエラーが、ネストされた項目について出ていました。
class UserSpotPhoto(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.PROTECT)
event = models.ForeignKey(Event, on_delete=models.PROTECT)
spot = models.ForeignKey(EventSpot, on_delete=models.PROTECT)
visit_date = models.DateField(blank=True, null=True)
photo = models.ImageField(upload_to="photos/%y/%m/%d/", verbose_name='画像', blank=True, null=True)
thumbnail = ImageSpecField(source='photo',
processors=[ResizeToFill(250,250)],
format="JPEG",
options={'quality': 60}
)
def __str__(self):
return f'{self.user} {self.spot} {self.event}'
このようなモデルで、user、event、spot がAPIとしては、ネストされた形になります。
これが、multipart/form-data の場合にはうまくできないようです。
これを解消するために、下記を参照させていただきました。
class UserSpotPhotoSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
user_id = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), write_only=True)
spot = EventSpotSerializer(read_only=True)
spot_id = serializers.PrimaryKeyRelatedField(queryset=EventSpot.objects.all(), write_only=True)
event = EventSerializer(read_only=True)
event_id = serializers.PrimaryKeyRelatedField(queryset=Event.objects.all(), write_only=True)
class Meta:
model = UserSpotPhoto
fields = '__all__'
def create(self, validated_date):
validated_date['user'] = validated_date.get('user_id', None)
if validated_date['user'] is None:
raise serializers.ValidationError("user not found.")
del validated_date['user_id']
validated_date['spot'] = validated_date.get('spot_id', None)
if validated_date['spot'] is None:
raise serializers.ValidationError("spot not found.")
del validated_date['spot_id']
validated_date['event'] = validated_date.get('event_id', None)
if validated_date['event'] is None:
raise serializers.ValidationError("event not found.")
del validated_date['event_id']
return UserSpotPhoto.objects.create(**validated_date)
これで、無事、ファイルアップロード機能が実現できました。
先は長い・・・