忙しい人のためのファイルアップロードAPIの設計
- ボディへの含め方は以下の3つ
- リクエストボディにそのまま入れる
- base64へ変換
-
multipart/form-data
にて送信
- 用途や要件に応じて適切な方式を選択することが大事
- 例1: 複数の音声/動画ファイルをアップロードする
-
multipart/form-data
形式を利用する
-
- 例2: 可変サイズのテキストファイルをアップロードする
- リクエストボディにそのまま入れる
- 例1: 複数の音声/動画ファイルをアップロードする
はじめに
こんにちは、株式会社インティメート・マージャーの @omillefeuillebot です。
今回は自分が実際にpython・FastAPIで実装するという観点から、ファイルアップロードAPIについて備忘録として残していこうと思います。
最近、「POSTでリクエストされたら、送信されたデータをGoogle Cloud Storage(以下、GCS)に保存する」というAPIを作る機会が何回かありました。
最初は調べればサッと作れるでしょうという軽い気持ちで取り組んでいました。
ですが、弊社CTOから「ファイルアップロードAPIは3種類あるんだよ、知ってた?」と言われ、知識が必要になる箇所がいくつかあるなと気が付きました。
そこで、先人の記事を参考にしながら、自分なりに調べたことを書いていきます。
ファイルアップロードの種類
ボディへの含め方として、大きく分けて以下の3つのタイプがあります。
- リクエストボディにそのまま入れる
- base64へ変換
-
multipart/form-data
にて送信
(参考文献: Upload APIの設計を調査してみた)
以下、それぞれのタイプについて実装例を交えて見ていきます。
前提
- 「リクエストを受けたデータを実行環境に保存する」サンプルコードを提示
- FastAPIの詳細な実装は省略
- 今回はAPIにリクエストする際、HTTPieを用いて確認
HTTPieは実際に行われたHTTPリクエスト、HTTPレスポンスの中身をチェックするために使用してみました。
リクエストボディにそのまま入れるタイプ
このタイプでは、リクエストボディの内容をそのままファイルに書き込むことで、シンプルに実装できます。
実装例
from fastapi import Body, FastAPI
app = FastAPI()
@app.post("/upload1")
def upload1(
text = Body(...),
):
with open("upload1.txt", "wt") as f:
f.write(text)
return {"message": "upload1.txt created."}
HTTPリクエスト・レスポンスの中身
$ http -v \
POST http://localhost/upload1 \
Content-Type:text/plain <<< "こんにちは"
POST /upload1 HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 16
Content-Type: text/plain
Host: localhost
User-Agent: HTTPie/3.2.2
こんにちは
HTTP/1.1 200 OK
content-length: 34
content-type: application/json
date: Mon, 09 Dec 2024 16:14:55 GMT
server: uvicorn
{
"message": "upload1.txt created."
}
メリット
シンプルな実装で済む
デメリット
ファイル名を指定するには、APIのパスやヘッダーで指定するなどの工夫が必要
base64変換して入れるタイプ
base64変換を利用する最大の利点は、JSON形式でデータを送信できる点です。
実装例
from fastapi import Body, FastAPI
from pydantic import BaseModel
app = FastAPI()
class FileUploadRequest(BaseModel):
filename: str
content: str
@app.post("/upload2")
def upload2(
file: FileUploadRequest = Body(...),
):
file_data = base64.b64decode(file.content)
with open(file.filename, "wb") as f:
f.write(file_data)
return {"message": f"{file.filename} created."}
HTTPリクエスト・レスポンスの中身
$ http -v \
POST http://localhost/upload2 \
Content-Type:application/json \
filename="beacon.gif" \
content="R0lGODlhAQABAGAAACH5BAEKAP8ALAAAAAABAAEAAAgEAP8FBAA7"
POST /upload2 HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 93
Content-Type: application/json
Host: localhost
User-Agent: HTTPie/3.2.2
{
"content": "R0lGODlhAQABAGAAACH5BAEKAP8ALAAAAAABAAEAAAgEAP8FBAA7",
"filename": "beacon.gif"
}
HTTP/1.1 200 OK
content-length: 33
content-type: application/json
date: Mon, 09 Dec 2024 15:20:03 GMT
server: uvicorn
{
"message": "beacon.gif created."
}
メリット
JSON形式を利用するため、柔軟な設計が可能
デメリット
base64変換をすることによりデータサイズが大きくなる
multipart/form-data
にて送信するタイプ
multipart/form-data
形式は、base64変換のように容量が余計に大きくならず、データをそのまま送信できるところが利点です。
また、複数のファイルを送信できたり、別データを付随させることもできます。
実装例
import shutil
from fastapi import FastAPI, File, UploadFile
app = FastAPI()
@app.post("/upload3")
def upload3(
file: UploadFile = File(...),
):
with open(file.filename, "wb") as f:
shutil.copyfileobj(file.file, f)
return {"message": f"{file.filename} created."}
HTTPリクエスト・レスポンスの中身
$ http -v --form \
POST http://localhost/upload3 \
accept:application/json \
Content-Type:multipart/form-data \
'file@upload3_1.txt;type=text/plain'
POST /upload3 HTTP/1.1
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 216
Content-Type: multipart/form-data; boundary=7d547d3a295b492abe589741eb1927d4
Host: localhost
User-Agent: HTTPie/3.2.2
accept: application/json
--7d547d3a295b492abe589741eb1927d4
Content-Disposition: form-data; name="file"; filename="upload3_1.txt"
Content-Type: text/plain
### upload3_1.txt
hogefuga
testです
--7d547d3a295b492abe589741eb1927d4--
HTTP/1.1 200 OK
content-length: 36
content-type: application/json
date: Mon, 09 Dec 2024 16:59:30 GMT
server: uvicorn
{
"message": "upload3_1.txt created."
}
複数ファイル対応の実装例
import shutil
from fastapi import FastAPI, File, UploadFile
app = FastAPI()
@app.post("/upload_files")
def upload_files(
files: list[UploadFile] = File(...),
):
files_list = []
for file in files:
with open(file.filename, "wb") as f:
shutil.copyfileobj(file.file, f)
files_list.append(file.filename)
return {"message": f"{','.join(files_list)} created."}
複数ファイル送信時のHTTPリクエスト・レスポンスの中身
$ http -v --form \
POST http://localhost/upload_files \
accept:application/json \
Content-Type:multipart/form-data \
'files@upload3_1.txt;type=text/plain' \
'files@upload3_2.txt;type=text/plain'
POST /upload_files HTTP/1.1
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 385
Content-Type: multipart/form-data; boundary=1030822b4ac54fd1bf1de06d156b08a3
Host: localhost
User-Agent: HTTPie/3.2.2
accept: application/json
--1030822b4ac54fd1bf1de06d156b08a3
Content-Disposition: form-data; name="files"; filename="upload3_1.txt"
Content-Type: text/plain
### upload3_1.txt
hogefuga
--1030822b4ac54fd1bf1de06d156b08a3
Content-Disposition: form-data; name="files"; filename="upload3_2.txt"
Content-Type: text/plain
### upload3_2.txt
hogehogefugafuga
--1030822b4ac54fd1bf1de06d156b08a3--
HTTP/1.1 200 OK
content-length: 50
content-type: application/json
date: Mon, 09 Dec 2024 16:57:18 GMT
server: uvicorn
{
"message": "upload3_1.txt,upload3_2.txt created."
}
メリット
複数ファイルに対応できる
デメリット
JSON形式と異なるため、API設計が統一されない場合がある
業務での活用事例
「POSTでリクエストが来たら、送信されたデータをGCSに保存する」APIを作成する機会がいくつかあったとはじめに申し上げましたが、当時はそれぞれ以下の要望がありました。
- 例1: 音声/動画ファイル(mp3, mp4, m4aなど)をアップロードしたい
- 将来的に複数ファイル同時にアップロードすることも視野にいれる
- 例2: サイズが可変なテキストファイルをアップロードしたい
- サイズがある程度大きいデータを受ける可能性がある
- サイズが可変なので最大値に合わせると効率が悪い
そこで、私は以下の方法で実装しました。
ケース1: 音声/動画ファイルのアップロードAPI
このケースでは multipart/form-data
形式でアップロードする方法を選択しました。
理由としては以下の通りです。
- そのままファイルを送信できるメリットが大きい
- 特にJSON形式にする必要がない
- 音声/動画ファイルはただでさえサイズが大きくなりがちなのに、base64エンコードをしていたらさらに容量が大きくなりパンクする
- 将来的に複数ファイルをアップロードできるように修正できる
実際の実装
from fastapi import FastAPI, File, UploadFile
from google.cloud import storage
app = FastAPI()
gcs_client = storage.Client()
bucket = gcs_client.bucket("gcs-bucket")
@app.post("/upload")
def upload(
file: UploadFile = File(...),
):
blob = bucket.blob(f"{file.filename}")
blob.upload_from_file(file.file, content_type=file.content_type)
return {"message": f"{file.filename} uploaded."}
例外処理などはここでは記述しておりませんのでご注意を
ケース2: サイズが可変なテキストファイルのアップロードAPI
このケースではデータをリクエストボディにそのまま入れる形式でアップロードする方法を選択しました。
理由としては以下の通りです。
- ファイル名などは外部から指定する必要がなかった(サーバー内の処理で決定)
- そのままファイルを送信するとメモリが足りなくなる恐れがある
また、この実装ではStarletteの .stream()
を用いてストリーム処理をさせることで、なるべく省メモリでファイルをアップロードできるように実装しています。
FastAPIはStarletteをベースとして構築されたWebフレームワークです。困ったことがあったらStarletteのドキュメントやコードを確認しにいくと、実装のヒントが見つかるかもしれません。
実際の実装
import uuid
import tempfile
from fastapi import FastAPI, Request
from google.cloud import storage
app = FastAPI()
gcs_client = storage.Client()
bucket = gcs_client.bucket("gcs-bucket")
@app.post("/upload")
async def upload(
request: Request,
):
key = f"hoge/{str(uuid.uuid4())[:8]}.txt"
with tempfile.NamedTemporaryFile("wb") as f:
async for chunk in request.stream():
f.write(chunk)
f.flush()
blob = bucket.blob(key)
blob.upload_from_filename(f.name)
return {"message": f"{key} uploaded."}
例外処理などはここでは記述しておりませんのでご注意を
まとめ
ファイルアップロードにはいくつかの方法があります。
ボディへの含め方 | メリット | デメリット | こんな時に使うといいかも |
---|---|---|---|
リクエストボディにそのまま入れる | シンプルな実装で済む | ファイル名などを指定するには工夫が必要 | シンプルに送信したい |
base64 | JSON形式を利用できる | データサイズが大きくなる | JSON形式で統一したい |
multipart/form-data | データをそのまま送信できる、複数データ送信可能 | JSON形式と異なる | 複数ファイルを効率よく送信したい |
それぞれのメリットデメリットがあるので、用途や要件に応じて適切な方式を選択することが重要です。
この記事が、ファイルアップロードAPIの実装時の参考になれば幸いです。
最後に
最後まで読んでくださり、ありがとうございました!
明日以降の記事もお楽しみに!