Streamlitのベストプラクティスを考えてみた

# はじめに

PythonのWebフレームワークの中でも、比較的最近(2018年)にできたStreamlitについてご存知でしょうか？LLMアプリケーションの構築や、機械学習を用いたデータの可視化を迅速に行うことができる素晴らしいフレームワークです。

今回は、業務で実際にStreamlitでWebアプリケーションを実装して感じた注意点について、実際の例を挙げながら個人的なベストプラクティスを紹介していこうと思います。

# 対象読者

- Streamlitを用いてサクッとLLMアプリケーションを作成したい方
- EDAした結果をサクッと可視化して、他人に共有したい方
- これから作成するアプリケーションにStreamlitを採用しようと思っている方

# 記事を読む上での注意点

個人的な感じたことを反映させたベストプラクティスなので、一部おかしな記述や不正確な情報が含まれているかもしれません。
 
もしお気付きの点やご指摘があれば、コメントで教えていただけると幸いです。

# Streamlitとは

Streamlitとは、簡単に言えば、PythonだけでWebアプリケーションのバックエンドもフロントエンドも実装することができる、魔法のようなフレームワークです。

 > Streamlit turns data scripts into shareable web apps in minutes.
All in pure Python. No front‑end experience required.

公式ドキュメントに書かれているように、データを手軽に可視化し、Webアプリケーションとして容易に共有することができます。また、Pythonの直感的なコードでフロントエンドを実装するための、豊富なウィジェットが用意されています。

さらに、デプロイも容易に行うことができ、高速で高品質プロトタイプを作成・公開することができます。

https://streamlit.io/

# ベストプラクティス

こんなに便利なフレームワークなのですが、便利さがゆえに、注意するべき点があるので、大きく分けて5つの個人的ベストプラクティスを紹介していきたいと思います！

## その1: 責務を適切に分離する

Streamlitの最大の利点は、Pythonだけで、すでに用意されているリッチなUIを用いてWebアプリケーションを作成することができる点です。
 
しかし、その反面、UIとデータ処理・API呼び出し等のビジネスロジックが密結合になりがちです。これは、一般的なWebアプリケーションにも当てはまる話ではありますが、保守性や変更容易性を保つために、Viewとロジックは適切に分離するべきです。

これは、適切なディレクトリ構造を定めておくことで回避することが可能です。所謂、MVCアーキテクチャのように、Model（データ構造）とView（見た目）、Controller（ロジック）で分離するのが一番やりやすいかなと思います。以下の記事に、詳しい例が挙げられているのでぜひ読んでみてください。

```sh
streamlit_app/
  ┗ main.py     # このファイルを実行
  ┗ views/      # componentを実装
    ┗ graph.py
  ┗ services/   # ビジネスロジックを実装
    ┗ calculate.py 
  ┗ models/     # データモデルを定義
    ┗ db.py
  ┗ ...
```

https://zenn.dev/enterrocken/articles/2aaab79961e386

## その2: キャッシュを利用する

Streamlitでは、長時間動作するアプリのパフォーマンス向上やメモリ使用量の最適化を目的に、キャッシュ機能を利用することが推奨されています。特に、計算結果やリソースを再利用する場合に役立つデコレータとして、以下の2つが用意されています

これらデコレータを関数に付与することで、1回目の呼び出し時は計算が行われ、キャッシュに結果が保存されます。2回目以降は、同じ引数で関数が呼び出されると保持しているキャッシュを参照して結果を返します。これにより、パフォーマンスの改善が期待できます。

### `@st.cache_data`
- 用途 : 
    - DataFrameやNumPy配列、APIのクエリ結果などの計算結果を返す関数に利用
- 特徴 : 
    - 関数が呼び出されるたびに新しいコピーを作成するので、安全に取り扱える
    - 迷ったらこっちを使っておけば、間違いないです

```python
@st.cache_data
def load_data_from_csv(file_path):
    return pd.read_csv(file_path)
```

### `@st.cache_resource` 
- 用途 : 
    - 機械学習モデルや、DBとの接続などのコード全体で使用され、何度もロードする必要のないものに対して用いる
- 特徴 : 
    - コピーは作成されず、関数の再実行やセッションをまたいで共有される
    - 返却された値を書き換えると、キャッシュに保存されている値が書き換えられるため、スレッドセーフに扱えるように注意が必要

```python
@st.cache_resource
def load_model(model_path):
    return some_ml_library.load_model(model_path)
```

### パラメータ

それぞれのデコレータについて、以下のパラメータを指定することができます。特に、`max_entries`については、巨大なデータをキャッシュする際などは、メモリを占有しすぎないように、最大数を定めておくことが推奨されています。

- `ttl` : キャッシュを残しておく時間(time-to-live) 
- `max_entries` : キャッシュするエントリの最大数

:::note warn
注意
`@st.cache`はdeprecateされ、現在は、それぞれの用途に対して最適化された`@st.cache_data`と`@st.cache_resource`の利用が推奨されています。
:::

https://docs.streamlit.io/develop/concepts/architecture/caching

## その3: 再レンダリング範囲を最小にする

以下のコードを見てください。これは、見出しとボタン・トグル・数値の入力欄を表示するシンプルなStreamlitのコードになります。ここで注意したいのは、ボタンやトグル・入力を行った時の挙動です。

StreamlitはユーザーがGUIに作用すると、コードを上から下まで全て再実行します。よって、ボタンを押しただけでも、全てのUIが再レンダリングされてしまいます。
```python
import streamlit as st

st.header("st.fragment test")
st.divider()

if st.button("push me"):
    st.write("you pushed me")

if st.toggle("toggle me"):
    st.write("you toggled me")

number = st.number_input("Insert a number")
```

### `@st.fragment`

なんとStreamlitはレンダリングの問題を解消するためのデコレータも用意してくれています。使い方は簡単で、UIを関数に切りだし、`@st.fragment`デコレータを付与してあげれば、その関数だけで再レンダリングされます。

### 修正例

修正されたコードが以下です。こうすることで、例えば、ボタンを押したとしても、再レンダリングされるのは、buttonコンポーネントだけになります。

このように、ユーザーとインタラクティブなUIは関数に切り出して、`st.fragment`を付与するのがベストプラクティスです。また、コンポーネントの再利用性も高まります。

```python
import streamlit as st


@st.fragment
def button():
    if st.button("push me"):
        st.write("you pushed me")


@st.fragment
def toggle():
    if st.toggle("toggle me"):
        st.write("you toggled me")


@st.fragment
def number_input():
    number = st.number_input("Insert a number")
    st.write(f"you inserted {number}")


st.header("st.fragment test")
st.divider()

button()
toggle()
number_input()
```

### Fragments vs Caching
`@st.cache_data`・`st.cache_resource`と`st.fragments`を併用することはできません。どちらを使うかは、UIに応じて、うまく使い分けてください。
- Fragments :  レンダリング範囲を狭くする
- Caching : レンダリングされた時に、2回目以降の呼び出しでは計算済みの値を使用する

## その4: メモリリークに注意する
Streamlitはアプリケーションの特性上、メモリリークに常に気をつける必要があります。実際に、メモリリークについての問題は、公式のDiscussにて複数投稿されています。

この問題は、ローカルで開発している際は感知しづらいです。実際にデプロイして、一定期間運用してみて、メモリ不足により動かなくなって気づくことが多いです。開発を行う際は、キャッシュや`st.session_state`を用いてメモリーを浪費しないように常に意識することが大切です。

また、以下の記事は、Streamlitのどこでメモリリークが発生しているかを丁寧に解説している記事です。本番環境が急に止まってしまうのは怖いので、こちらで確認してみると良いと思います。

https://blog.streamlit.io/3-steps-to-fix-app-memory-leaks/

## その5: 複雑なことはできるだけやらない

既存のウィジェットは、あくまで入出力部分の責務を担っており、ロジックが、できる限り削ぎ落とされたシンプルなものになっています。


ですが、このUIだけでは実現できない要件がしばしば出てきます。そんな時に用いるのがStreamlit Componentsです。

Streamlit Componentsとは、HTML/CSS/JavaScriptはもちろん、React等のライブラリを用いて独自のコンポーネントを作成できる機能です。すでに豊富なウィジェットが用意させていますが、独自に見た目や機能をカスタマイズしたコンポーネントを作成できます。

https://docs.streamlit.io/develop/concepts/custom-components

ですが、どうやったってStreamlitの既存ウィジェットで解決することのできない要件が出た場合は、他の技術を使う必要が出てきたのではないか？と考えてみるのが良いと思います。Streamlit Componentsで複雑なコードを実装しようとすると、ふとReactで実装した方が早くないか？と思うことがしばしばあります。

 Streamlitが得意とするユースケースはミニマムで、高速にプロトタイプを作成することです。Stremlit単体で複雑なことをしようとすると、なかなか難しくなってきます。
 
 Streamlit Componentsは非常に便利なAPIですが、使用を検討する前に再考してみてください。

:::note warn
こちらは最も賛否が分かれる部分かと思います。外部ライブラリでStreamlit Componentsを利用した便利なライブラリがすでに多く公開されていますので、こちらの利用も検討してみてください。
:::

# おわりに

業務で実際にStreamlitを用いて、苦しんだ経験をもとに、自分なりのベストプラクティスを書いてみました。今後、Streamlitの使用を検討されている方の参考に少しでもなれば嬉しいです。ここまで読んでいただき、ありがとうございました。
  
ご指摘やアドバイスがあればコメントで教えていただけると幸いです。