Closed8

「Reflex」を使ってPythonでWebアプリを作成してみる

kun432kun432

https://qiita.com/SFITB/items/c3361979b86f441993ff

https://zenn.dev/neka_nat/articles/f2f5b6ebeb049a#reflex(旧pynecone)

普段から競馬のデータ解析アプリでStreamlitを使っているのだけども、もうちょっと汎用的な物を作りたいなと思ってて、最近はDjangoに手を出していた。

上記の記事ではDjangoやFlaskみたいなWebアプリケーションフレームワークは含まれていないのだけど、他にもいろいろあるんだな。

とりあえずその中では「Reflex」が気になったので少し試してみる。

kun432kun432

https://github.com/reflex-dev/reflex

Reflex

Reflexは、純粋なPythonでフルスタックのWebアプリケーションを構築するためのライブラリである。

主な特徴

  • 純粋なPython - アプリのフロントエンドとバックエンドをすべてPythonで記述するため、Javascriptを学ぶ必要がない。
  • 完全な柔軟性 - Reflexは簡単に始められるが、複雑なアプリにも拡張できる。
  • 即座にデプロイ - ビルド後、1つのコマンドでアプリをデプロイするか、独自のサーバーにホストする。

公式のチュートリアル的ドキュメントはこれ。AIチャットアプリをReflexで作るというものみたい。

https://reflex.dev/docs/tutorial/intro/

ローカルでやる

Reflexのインストール&プロジェクトの作成

作業ディレクトリを作成

$ mkdir chatapp
$ cd chatapp

仮想環境を作成

$ python -m venv .venv
$ source .venv/bin/activate

Reflexインストール

$ pip install reflex

Reflexのプロジェクトを作成

$ reflex init

ドキュメントにはなかった、テンプレートの選択を聞かれる。一旦"blank"で進める。

───────────────────────────────────────────────────────────────────────────────────────── Initializing chatapp ─────────────────────────────────────────────────────────────────────────────────────────
[21:39:10] Initializing the web directory.                                                                                                                                                 console.py:95

Get started with a template:
(0) blank (https://blank-template.reflex.run) - A minimal template
(1) dashboard (https://dashboard.reflex.run) - A dashboard with tables and graphs
(2) chat (https://chat.reflex.run) - A ChatGPT clone
(3) sidebar (https://sidebar-template.reflex.run) - A template with a sidebar to navigate pages
Which template would you like to use? (0): 0

初期化完了したっぽい。

[21:41:06] Initializing the app directory.                                                                                                                                                 console.py:95
Success: Initialized chatapp

プロジェクトのディレクトリ構成は、Reflexに関係ないものは除くとこんな感じだった。

$ tree -I "__pycache__|.venv|.envrc" -a
.
├── .gitignore
├── .web
│   ├── .gitignore
│   ├── components
│   │   └── reflex
│   │       ├── chakra_color_mode_provider.js
│   │       └── radix_themes_color_mode_provider.js
│   ├── jsconfig.json
│   ├── next.config.js
│   ├── package.json
│   ├── postcss.config.js
│   ├── public
│   ├── reflex.json
│   ├── styles
│   │   └── tailwind.css
│   └── utils
│       ├── client_side_routing.js
│       ├── helpers
│       │   ├── dataeditor.js
│       │   └── range.js
│       └── state.js
├── assets
│   └── favicon.ico
├── chatapp
│   ├── __init__.py
│   └── chatapp.py
├── requirements.txt
└── rxconfig.py

プロジェクトのディレクトリ構成はここに書いてある

https://reflex.dev/docs/getting-started/project-structure/

ざっくりこんな感じらしい。

  • .web/
    • コンパイルされたJavascriptファイルがここに格納される。
  • assets/
    • スタティックなファイルの格納場所
  • chatapp/
    • アプリの本体
    • アプリと同じ名前のディレクトリおよびファイルが生成される(カスタマイズは可能らしい)
  • rxconfig.py
    • アプリの設定を記載する

アプリの起動

では、とりあえず起動してみる。

$ reflex run

フロントエンドの依存ライブラリがインストールされる

───────────────────────────────────────────────────────────────────────────────────────── Starting Reflex App ──────────────────────────────────────────────────────────────────────────────────────────
Compiling: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 13/13 0:00:00
⠋ Installing base frontend packages  Resolving dependencies

完了するとアプリが3000番ポートで起動する。

───────────────────────────────────────────────────────────────────────────────────────── Starting Reflex App ──────────────────────────────────────────────────────────────────────────────────────────
Compiling: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 13/13 0:00:00
───────────────────────────────────────────────────────────────────────────────────────────── App Running ──────────────────────────────────────────────────────────────────────────────────────────────
App running at: http://localhost:3000

ブラウザでアクセスするとこんな画面が表示される。

Reflexでは、フロントエンドとバックエンドが分かれており、フロントが3000番ポートで、バックエンドが8000番ポートとなっている。なので同時にバックエンドも起動している。

$ curl localhost:8000/ping
"pong"

アーキテクチャについてはこちら
https://reflex.dev/docs/getting-started/how-reflex-works/

Reflexのアーキテクチャ

フルスタックのウェブアプリは、フロントエンドとバックエンドで構成されている。フロントエンドはユーザーインターフェイスであり、ユーザーのブラウザ上で動作するウェブページとして提供される。バックエンドはロジックと状態管理(データベースやAPIなど)を処理し、サーバー上で実行される。

従来のウェブ開発では、これらは通常2つの別々のアプリで、異なるフレームワークや言語で書かれていることが多い。例えば、FlaskのバックエンドとReactのフロントエンドを組み合わせることができる。このアプローチでは、2つの別々のアプリを維持しなければならず、フロントエンドとバックエンドを接続するために多くの定型的なコードを書くことになる。

Reflexでは、フロントエンドとバックエンドの両方を単一のコードベースで定義し、すべてにPythonを使用することで、このプロセスを簡素化したいと考えました。開発者はアプリのロジックだけを心配すればよく、低レベルの実装の詳細については心配する必要はありません。

TLDR

Reflexアプリは、 React フロントエンドアプリと FastAPI バックエンドアプリにコンパイルされます。UIのみがJavascriptにコンパイルされ、アプリのロジックと状態管理はすべてPythonのまま、サーバー上で実行されます。Reflexは WebSocketを使って フロントエンドからバックエンドにイベントを送信し、バックエンドからフロントエンドに状態の更新を送信する。

下図は、Reflexアプリがどのように動作するかの詳細な概要です。次のセクションでは、各パーツの詳細を説明します。


refered from: https://reflex.dev/docs/getting-started/how-reflex-works/#the-reflex-architecture

kun432kun432

フロントエンド

https://reflex.dev/docs/tutorial/frontend/

フロントエンドを修正していく。フロントエンドは独立した再利用可能なコンポーネントから構成される。

chatapp/chatapp.pyをまるっと書き換える。reflex runは起動したままでOK。

chatapp/chatapp.py
import reflex as rx


def index() -> rx.Component:
    return rx.container(
        rx.box(
            "Reflexとは何ですか?",
            # ユーザからの質問は右に配置
            text_align="right",
        ),
        rx.box(
            "PythonだけでWebアプリケーションを作る1つの方法です!",
            # 回答は左に配置
            text_align="left",
        ),
    )


# アプリにstateとpageを追加する
app = rx.App()
app.add_page(index)

コードを見ると、

  • コンポーネント(rx.container)の中に複数のコンポーネント(rx.box)を配置
  • コンポーネントにプロパティ(props)を設定
  • アプリを初期化
  • アプリにコンポーネントをページとして追加

って感じで雰囲気がわかる。

コンポーネントは再利用が可能。

chatapp/chatapp.py
import reflex as rx


def qa(question: str, answer: str) -> rx.Component:
    return rx.box(
        rx.box(question, text_align="right"),
        rx.box(answer, text_align="left"),
        margin_y="1em",
    )


def chat() -> rx.Component:
    qa_pairs = [
        (
            "Reflexとは何ですか?",
            "PythonだけでWebアプリケーションを作る1つの方法です!",
        ),
        (
            "それを使って何ができますか?",
            "シンプルなWebサイトから複雑なWebアプリまで何でも作れます!",
        ),
    ]
    return rx.box(
        *[
            qa(question, answer)
            for question, answer in qa_pairs
        ]
    )


def index() -> rx.Component:
    return rx.container(chat())


app = rx.App()
app.add_page(index)

入力フォームを追加する。

chatapp/chatapp.py
import reflex as rx


def qa(question: str, answer: str) -> rx.Component:
    return rx.box(
        rx.box(question, text_align="right"),
        rx.box(answer, text_align="left"),
        margin_y="1em",
    )


def chat() -> rx.Component:
    qa_pairs = [
        (
            "Reflexとは何ですか?",
            "PythonだけでWebアプリケーションを作る1つの方法です!",
        ),
        (
            "それを使って何ができますか?",
            "シンプルなWebサイトから複雑なWebアプリまで何でも作れます!",
        ),
    ]
    return rx.box(
        *[
            qa(question, answer)
            for question, answer in qa_pairs
        ]
    )


def action_bar() -> rx.Component:
    return rx.hstack(
        rx.input(placeholder="質問を入力してください"),
        rx.button("質問する"),
    )


def index() -> rx.Component:
    return rx.container(
        chat(),
        action_bar(),
    )


app = rx.App()
app.add_page(index)

スタイリングを加える。chatapp/style.pyを作成。

chatapp/style.py
import reflex as rx

# 質問・回答の共通のスタイル
shadow = "rgba(0, 0, 0, 0.15) 0px 2px 8px"
chat_margin = "20%"
message_style = dict(
    padding="1em",
    border_radius="5px",
    margin_y="0.5em",
    box_shadow=shadow,
    max_width="30em",
    display="inline-block",
)

# 質問回答に特定のスタイルを適用
question_style = message_style | dict(
    margin_left=chat_margin,
    background_color=rx.color("gray", 4),
)
answer_style = message_style | dict(
    margin_right=chat_margin,
    background_color=rx.color("accent", 8),
)

# アクションバーにスタイルを適用
input_style = dict(
    border_width="1px", padding="1em", box_shadow=shadow
)
button_style = dict(
    background_color=rx.color("accent", 10),
    box_shadow=shadow,
)

上記をchatapp/chatapp.pyの各コンポーネントに適用する。

chatapp/chatapp.py
import reflex as rx
from chatapp import style


def qa(question: str, answer: str) -> rx.Component:
    return rx.box(
        rx.box(
            rx.text(question, style=style.question_style),
            text_align="right"
        ),
        rx.box(
            rx.text(answer, style=style.answer_style),
            text_align="left"
        ),
        margin_y="1em",
    )


def chat() -> rx.Component:
    qa_pairs = [
        (
            "Reflexとは何ですか?",
            "PythonだけでWebアプリケーションを作る1つの方法です!",
        ),
        (
            "それを使って何ができますか?",
            "シンプルなWebサイトから複雑なWebアプリまで何でも作れます!",
        ),
    ]
    return rx.box(
        *[
            qa(question, answer)
            for question, answer in qa_pairs
        ]
    )


def action_bar() -> rx.Component:
    return rx.hstack(
        rx.input(
            placeholder="質問を入力してください",
            style=style.input_style,
        ),
        rx.button(
            "質問する",
            style=style.button_style,
        ),
    )


def index() -> rx.Component:
    return rx.container(
        rx.vstack(
            chat(),
            action_bar(),
            align="center",
        )
    )


app = rx.App()
app.add_page(index)

kun432kun432

State

Stateは状態を管理する。この辺はStreamlitなんかと同じだと思う。

chatapp/state.pyを作成

chatapp/state.py
import reflex as rx


class State(rx.State):
    # 現在の質問
    question: str

    # 会話履歴を (質問,回答)のタプルのリストで管理
    chat_history: list[tuple[str, str]]

    def answer(self):
        # とりあえず何でも「わからない」と返す
        answer = "ごめんなさい、わかりません。"
        self.chat_history.append((self.question, answer))

Stateをフロントエンドのコンポーネントに紐づける。Stateクラスをインポートして、まず、会話を表示するchatコンポーネントに紐づける。

chatapp/chatapp.py
from chatapp.state import State

(snip)

def chat() -> rx.Component:
    return rx.box(
        rx.foreach(
            State.chat_history,
            lambda messages: qa(messages[0], messages[1])
        )
    )

(snip)

Stateの値は変わる上、コンパイル時点ではわからないので、Pythonの普通のforループは使えないらしく、そのかわりにrx.foreachを使う。で、Stateから会話履歴のペアを取り出して、lambda関数でqaに渡すと。なるほど。

次に質問を入力したときにStateクラスのanswerメソッドを実行するようにする。

chatapp/chatapp.py
(snip)

def action_bar() -> rx.Component:
    return rx.hstack(
        rx.input(
            placeholder="質問を入力してください",
            on_change=State.set_question,
            style=style.input_style,
        ),
        rx.button(
            "質問する",
            on_click=State.answer,
            style=style.button_style
        ),
    )

(snip)

on_clickイベントでanswerがイベントハンドラになるのはわかるんだけど、on_changeはなんぞ?と思ったら、どうやらset_変数名でStateの変数をセットするイベントハンドラになる、つまりsetterメソッドになるみたい。

https://reflex.dev/docs/events/setters/

実際に実行してみると、ちゃんとStateが管理されているので過去の会話が続いていく。

で、ここ

通常は入力した内容を送信すると入力欄はクリアされるのだけど、ここが残ったままになっている。ここはon_changeでセットしているからなんだろうな。で、これをクリアする。

Stateのanswerメソッドが実行されたら最後に現在の質問を空にする。

chatapp/state.py

class State(rx.State):
   (snip)
    def answer(self):
        # とりあえず何でも「わからない」と返す
        answer = "ごめんなさい、わかりません。"
        self.chat_history.append((self.question, answer))
        self.question = ""      # ここを追加

で、これをテキストボックスの値にセットする。

chatapp/chatapp.py
(snip)

def action_bar() -> rx.Component:
    return rx.hstack(
        rx.input(
            value=State.question,     # ここを追加
            placeholder="質問を入力してください",
            on_change=State.set_question,
            style=style.input_style,
        ),
        rx.button(
            "質問する",
            on_click=State.answer,
            style=style.button_style
        ),
    )

(snip)

消えている。

ストリーミングで返すこともできる。

chatapp/state.py
import reflex as rx
import asyncio     # 追加


class State(rx.State):

    # 非同期関数として定義
    async def answer(self):
        # とりあえず何でも「わからない」と返す
        answer = "ごめんなさい、わかりません。"
        # 回答を空のままで一旦質問・回答のペアを会話履歴にを追加
        self.chat_history.append((self.question, ""))
        # テキストボックスの入力をクリア
        self.question = ""
        # 上記を反映するために最初にyieldする
        yield

        # 回答をストリーミングっぽくするためにsleepいれて1文字ずつ出力
        for i in range(len(answer)):
            await asyncio.sleep(0.1)
            self.chat_history[-1] = (
                self.chat_history[-1][0],
                answer[: i+1],
            )
            yield

kun432kun432

Final App

ということで、最後にOpenAIを使って、ストリーミングチャットができるようにする。

パッケージを追加。

$ pip install openai python-dotenv

APIキーを準備

.env
OPENAI_API_KEY="sk-XXXXXXXXXXXXXX"

chatapp/state.pyを以下のように修正。

chatapp/state.py
import reflex as rx
import asyncio
from openai import AsyncOpenAI
from dotenv import load_dotenv
import os

load_dotenv(verbose=True)

class State(rx.State):
    # 現在の質問
    question: str

    # 会話履歴を (質問,回答)のタプルのリストで管理
    chat_history: list[tuple[str, str]]

    async def answer(self):
        client = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])
        session = await client.chat.completions.create(
            model="gpt-3.5-turbo-0125",
            messages=[
              {"role": "user", "content": self.question}
            ],
            stop=None,
            temperature=0.7,
            stream=True,
        )
        # 回答を空のままで一旦質問・回答のペアを会話履歴にを追加
        answer = ""
        self.chat_history.append((self.question, answer))
        # テキストボックスの入力をクリア
        self.question = ""
        # 上記を反映するために最初にyieldする
        yield

        async for item in session:
            if hasattr(item.choices[0].delta, "content"):
                if item.choices[0].delta.content is None:
                    # 'None'になったら応答の終了
                    break
                answer += item.choices[0].delta.content
                self.chat_history[-1] = (self.chat_history[-1][0], answer)
                yield

ただ、この例は単に会話履歴がReflexで管理されているというだけで、OpenAIのリクエストには会話履歴が渡されてない。のでちょっと修正した。

chatapp/state.py
import reflex as rx
import asyncio
from openai import AsyncOpenAI
from dotenv import load_dotenv
import os

load_dotenv(verbose=True)

class State(rx.State):
    # 現在の質問
    question: str

    # 会話履歴を (質問,回答)のタプルのリストで管理
    chat_history: list[tuple[str, str]]

    async def answer(self):
        client = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])
        # messagesオブジェクト初期化してシステムプロンプト定義
        messages=[
            {"role": "system", "content": "あなたは大阪のおばちゃんです。元気に明るく、大阪弁で会話します。"}
        ]
        # 過去の会話履歴をmessagesオブジェクトに追加
        for chat in self.chat_history:
            messages.extend([
                {"role": "user", "content": chat[0]},
                {"role": "assistant", "content": chat[1]},
            ])
        # 現在の質問をmessagesオブジェクトに追加
        messages.append({"role": "user", "content": self.question})
        session = await client.chat.completions.create(
            model="gpt-3.5-turbo-0125",
            messages=messages,
            stop=None,
            temperature=0.7,
            stream=True,
        )
        # 回答を空のままで一旦質問・回答のペアを会話履歴にを追加
        answer = ""
        self.chat_history.append((self.question, answer))
        # テキストボックスの入力をクリア
        self.question = ""
        # 上記を反映するために最初にyieldする
        yield

        async for item in session:
            if hasattr(item.choices[0].delta, "content"):
                if item.choices[0].delta.content is None:
                    # 'None'になったら応答の終了
                    break
                answer += item.choices[0].delta.content
                self.chat_history[-1] = (self.chat_history[-1][0], answer)
                yield

こんな感じで会話が続いている。

kun432kun432

ここまでの所感

  • 非常によくできたチュートリアルで、めちゃめちゃわかりやすかった。
  • やる前は、Djangoみたいなフレームワーク的なものかなー?と勝手に推測してたけど、使い勝手的にはStreamlitとかとそんなに変わらない。
    • ただStreamlitとかよりもレイアウトの融通はかなり聞きそう。
    • 逆にDjango使うようなそこそこの規模のものになるとちょっと大変な気もする。
  • フロントエンドとバックエンドが分かれているのはやはり良いと思う。
    • ピュアPythonといいつつフロントエンドはReactみたいだけども
    • Pythonで書いてJSに変換されるので、書いてる側としては気にしなくていいのは良い。
  • 全体的にドキュメントがとてもわかりやすい。章立て見てても探しやすそうな気がする。
    • 多分コンポーネントがスッキリしているのだと思う。

ちょっと試しただけだし、あくまでも個人の現時点での感覚だけど、チュートリアル体験がとても良かったので、もう少し使いこんでみたいなーと感じた。

kun432kun432

あと気になったところ。

Streamlitみたいなページの概念がある。
https://reflex.dev/docs/pages/routes/

FastAPI的な使い方もできる(というかFastAPIを使っているみたい)
https://reflex.dev/docs/api-routes/overview/

DBも考慮されている。
https://reflex.dev/docs/database/overview/

ホスティングサービスもある。reflex deployで一発デプロイできるみたい。
https://reflex.dev/docs/hosting/deploy-quick-start/

セルフホスティングについても。フロントエンドを静的ファイルに出力できるってのはS3とかに上げれるってことなのかな?あとコンテナもある。
https://reflex.dev/docs/hosting/self-hosting/

デプロイ周り、結構整備されている感ある。

このスクラップは2024/05/07にクローズされました