streamlit+FastAPIを使っていたアプリのFastAPI部分を廃止してstreamlitだけに書き直したら、簡潔で速くなった話

# はじめに

仕事でとあるアプリ開発のプロジェクトを引き継ぐことになったが、そのアプリはなぜかFastAPIでバックエンドを作って、streamlitでフロントエンドだけ作る、という形で動いているのです。

ネットで調べてみたらこういう方法でアプリを作っている例もあるとわかりましたが、そもそもこんなことが本当に必要でしょうか？と、疑問を思ってしまいました。

確かに「FastAPI+JavaScript又は何等かのフレームワーク」はよくあるパターンです。私もいつも「FastAPI+vue.js」を使っています。

しかしstreamlit単体も同時にフロントエンドとバックエンドにもなれます。だから基本的にわざわざ個別でパックエンドを作る必要がないはずですね。

streamlitも使っているのに、まだFastAPIを一緒に使うなんて、「[ラーメンライス](https://ja.wikipedia.org/wiki/ラーメンライス)」か「焼きそばパン」みたい！と、つい思ってしまいました。

そう考えると、私は「streamlit+FastAPI」から「streamlitだけ」という形にアプリを書き直すことにしました。結果としてコードの複雑さも減ってわかりやすくなりますし、何より速度も上がります。

ということで、今回は似たような改革の例を説明します。実際の仕事とは内容は全然違いますが、概要は同じです。


# 今回作る例の概要

ここで紹介する仮のアプリは直接私の会社で開発しているアプリとは関係ありません。ただし共通点としては：
- ウェブページにフォーム入力がある
- pydanticでフォームをオブジェクトとして扱う
- pandasの`.read_csv`で.csvファイルからデータを読み込む
- フォームの条件に応じてデータフレームのデータを絞り込んでウェブページに表示する
- 「streamlit+FastAPI」から「streamlit単体」に乗り換える

実際の仕事でのアプリはAI関連の処理も含まれて、pytorch、scikit-learn、mecab、transformersなども使われて、かなり複雑な処理もしている本格的なAIアプリですが、今回はこれについて触れません。直接streamlitかFastAPIに関わる部分はpandasとpydanticだけなので今回はその部分だけ模擬します。

どんなデータで試していいか迷っていたのですが、今年2月の記事で作成したsuumo物件データセットがあるからこれを使います。

https://qiita.com/phyblas/items/2b447c8f33afa6c8721c

まずこの記事の「データ取得と準備」の項までのコードを実行したら「suumo_df.csv」というファイルができます。

vscodeなどで中身を見たらこんな感じの.csvファイル。簡単にpandasで読み込めます。

![2024-12-22-16.41.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/0522a18b-139b-9d82-f7ef-d96a6b53a9b2.png)

作るのはこのように、「建物種類」、「区」、「専有面積」、「家賃」、「拘り条件」で絞り込んだデータフレームを表示するページです。

![2024-12-24-06.05.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/78808deb-ca03-318c-782e-8c57fa6cded6.png)

特にボタンを使わずに、条件が変えられたら即時結果を反映させるのです。

因みに、比較のためにここにデータを取得するのにかかった時間も表示させています。ここで「0.16秒」の結果になっているのはstreamlit+FastAPIを使った結果です。streamlitだけを使う場合は見た目は同じですが、ここに表示される時間は「0.07秒」程度になります。つまり2倍くらい早くなります。

このようなアプリを実装してみます。


# streamlit+FastAPIで作る場合

まずstreamlitとFastAPIを一緒に使う方法です。

### FastAPIによるAPI

```python:api.py
from fastapi import FastAPI
from pydantic import BaseModel
import pandas as pd

class Shibori(BaseModel):
    shurui: str
    ku: str
    menseki: tuple[float,float]
    yachin: tuple[int,int]
    kodawari: list
    
app = FastAPI()

@app.post('/data_shutoku')
async def data_shutoku(shibori: Shibori):
    df = pd.read_csv('suumo_df.csv')
    if(shibori.shurui!='未指定'):
        df = df[df['建物種類']==shibori.shurui]
    if(shibori.ku):
        df = df[df['区']==shibori.ku]

    df = df[(df['専有面積']>=shibori.menseki[0])&(df['専有面積']<=shibori.menseki[1])]
    df = df[(df['家賃']>=shibori.yachin[0])&(df['家賃']<=shibori.yachin[1])]
    
    for kodawari in shibori.kodawari:
        df = df[df[kodawari]==1]

    return df.to_json(orient='records',force_ascii=False)
```

そして実行してAPIを起動します。
```
uvicorn api:app --reload
```

これで`http://127.0.0.1:8000/data_shutoku`からデータを取得できます。

### streamlitによるフロント

```python:app.py
import streamlit as st
import pandas as pd
import requests
from io import StringIO
import time

shibori = {
    'shurui': '未指定',
    'ku': '',
    'menseki': (5,100),
    'yachin': (10000,1000000),
    'kodawari': []
}

st.write('調べたい物件の条件')
c1,c2 = st.columns([0.7,0.3])
with c1:
    shurui_lis = ['未指定','アパート','マンション','一戸建て']
    shibori['shurui'] = st.radio('建物種類',shurui_lis,horizontal=True)
with c2:
    ku_lis = ['','城南','南','東','西','早良','中央','博多']
    shibori['ku'] = st.selectbox('区',ku_lis)

c1,c2 = st.columns(2)
with c1:
    shibori['menseki'] = st.slider('専有面積',5,100,(5,100),1)
with c2:
    shibori['yachin'] = st.slider('家賃',10000,1000000,(10000,1000000),10000)

kodawari_lis = ['バス・トイレ別','温水洗浄便座','室内洗濯機置場','洗面所独立','ロフト','家具家電付き','宅配ボックス','敷地内ゴミ置場','バルコニー付','都市ガス']
shibori['kodawari'] = st.multiselect('条件',kodawari_lis,['温水洗浄便座'])

t0 = time.time()
resp = requests.post(
    'http://127.0.0.1:8000/data_shutoku',
    json=shibori,
)
if(resp.status_code==200):
    df = pd.read_json(StringIO(resp.json()))
    df.index += 1
    if(not df.empty):
        st.success(f'データ取得時間: {time.time()-t0:.3f}秒')
        st.write(f'物件数: {len(df)}')
        st.dataframe(df)
    else:
        st.warning('該当の物件がありません')
else:
    st.error('エラー発生')
```

そして実行して起動します。
```
streamlit run app.py
```

これで`http://localhost:8501/`からアプリを使えます。そして上述のページが見えるでしょう。


# streamlitだけで作る場合

FastAPIなどのAPIを使わずに直接csvから読み込んだデータフレームをstreamlitに渡すこともできるので、pydanticのクラスとデータ取得の関数も同じファイルに定義することができます。

これでコードの全部です。
```python:app.py
import streamlit as st
from pydantic import BaseModel
import pandas as pd
import time


class Shibori(BaseModel):
    shurui: str
    ku: str
    menseki: tuple[float,float]
    yachin: tuple[int,int]
    kodawari: list

def data_shutoku(shibori: Shibori):
    df = pd.read_csv('suumo_df.csv')
    if(shibori.shurui!='未指定'):
        df = df[df['建物種類']==shibori.shurui]
    if(shibori.ku):
        df = df[df['区']==shibori.ku]

    df = df[(df['専有面積']>=shibori.menseki[0])&(df['専有面積']<=shibori.menseki[1])]
    df = df[(df['家賃']>=shibori.yachin[0])&(df['家賃']<=shibori.yachin[1])]
    
    for kodawari in shibori.kodawari:
        df = df[df[kodawari]==1]

    return df.reset_index(drop=True)


shibori = Shibori(
    shurui='未指定',
    ku='',
    menseki=(5,100),
    yachin=(10000,1000000),
    kodawari=[]
)

st.write('調べたい物件の条件')
c1,c2 = st.columns([0.7,0.3])
with c1:
    shurui_lis = ['未指定','アパート','マンション','一戸建て']
    shibori.shurui = st.radio('建物種類',shurui_lis,horizontal=True)
with c2:
    ku_lis = ['','城南','南','東','西','早良','中央','博多']
    shibori.ku = st.selectbox('区',ku_lis)

c1,c2 = st.columns(2)
with c1:
    shibori.menseki = st.slider('専有面積',5,100,(5,100),1)
with c2:
    shibori.yachin = st.slider('家賃',10000,1000000,(10000,1000000),10000)

kodawari_lis = ['バス・トイレ別','温水洗浄便座','室内洗濯機置場','洗面所独立','ロフト','家具家電付き','宅配ボックス','敷地内ゴミ置場','バルコニー付','都市ガス']
shibori.kodawari = st.multiselect('条件',kodawari_lis,['温水洗浄便座'])

t0 = time.time()
try:
    df = data_shutoku(shibori)
    df.index += 1
    if(not df.empty):
        st.success(f'データ取得時間: {time.time()-t0:.3f}秒')
        st.write(f'物件数: {len(df)}')
        st.dataframe(df)
    else:
        st.warning('該当の物件がありません')
except:
    st.error('エラー発生')
```

そしてこれだけ実行して起動します。
```
streamlit run app.py
```

結果は同じはずですが、速度の違いが見えるでしょう。


# 纏め

上述のこの2つの例を比べてみたら、違いはこんな感じです。
<table>
<tr><td><strong>streamlit + FastAPI</strong></td><td><strong>streamlit単体</strong></td></tr>
<tr><td>サーバーを2つ起動しなければならない</td><td>streamlitのサーバー一つで十分</td></tr>
<tr><td>2つのサーバーの間のやり取りする必要がある</td><td>全部streamlitのサーバーで行われる</td></tr>
<tr><td>データフレームをjsonに変換してから、再びjsonからデータフレームに戻す</td><td>データフレームをそのまま表示する</td></tr>
<tr><td>遅い</td><td>早い</td></tr>
<tr><td>複雑</td><td>簡単</td></tr>
</table>

結果としてstreamlitのみの構造で書き換えると、複雑さも減ってコードがわかりやすくなるし、速度も明らかに上がります。

特に時間がかかるのは、requestsで2つのサーバーの間のやり取りをする必要があるというところです。フロントエンドからフォームを送って、データフレームを取得するというやり取り。しかもデータフレームをそのまま送ることもできなく、わざわざjsonに変換して、再びデータフレームに戻す必要があるからこれも時間がかかります。

結果として速度は2倍くらいの差です。それにこれはローカルで試した結果ですが、実際にこれをウェブサイトにしたら違いはもっと明らかかもしれません。やはり通信とデータの変換は時間のロストです。今回データ1万程度だから特に問題ないかもしれませんが、もっと多くなると数秒かかってリアルタイム感がなくなるでしょう。

更にコードはstreamlitとFastAPI両方を書かなければならないとなると、必要な知識が多くなって複雑です。エラーが出る時にどっちからの間違いかチェックする作業も手間がかかります。それなのに効果も悪くなるとは……。そうなると、こうする意味ないでしょう。

尚、こう書いたら勘違いさせるかもしれませんが、別にFastAPIが悪いと言いたいわけではなく、寧ろ私はFastAPIが好きです。いつも使っています。今までFastAPI関連の記事も沢山書きました。仕事でFastAPIを使っているプロジェクトもあります（フロントエンドの方はJavaScript）。FastAPIはやはり書きやすくて早いです。

しかしFastAPIをstreamlitと一緒に使うと寧ろ複雑で遅いです。こんな組み合わせよりも、streamlit単体の方がいいです。或いはstreamlitを廃止して、代わりにvue.jsなどをFastAPIと組み合わせしてもいいと思います。

FastAPIとstreamlitどっちも優秀なフレームワークだと思いますが、使い方次第不向きもあるでしょう。

もしかしてFastAPIとstreamlitを一緒に使う必要がある場合もあるかもしれませんが、少なくとも私のやっているプロジェクトはこうする意味やメリットが見えません。だからこうやってstreamlit単体に書き換えて、いい結果になったのです。

大事なのは各プロジェクトにとってどんなやり方が一番いいか見極めることですね。


# 参考

[StreamlitとFastAPIを使ってメモアプリを作ってみた](https://qiita.com/nanndot/items/03f986059309d386eeae)
[StreamlitとFastAPIでwebアプリを作ってみた（概要編）](https://qiita.com/kizataka/items/061624fdcb59c6437b84)
[StreamlitとFastAPIで非同期推論MLアプリを作る](https://zenn.dev/dhirooka/articles/f82744d2475b68)
[poetry+fastapi+render+streamlitを用いて機械学習で推論するapiを作成してみた](https://zenn.dev/k_takumi/articles/randomforest-fastapi)
[Pydantic 入門](https://qiita.com/0622okakyo/items/d1dcb896621907f9002b)