LoginSignup
5
6

More than 1 year has passed since last update.

ReactとFastAPIで作るアンケート集計ページ

Posted at

はじめに

社内勉強会を週一回開催して終了後に簡単なアンケート(Formsを利用)を取っており、アンケート回答結果は集計して参加してくれた人たちに共有しています。
この時、アンケート集計作業は下図のような手順でExcelを操作して手作業で行っており勉強会開催毎に20分程度の時間がかかって片手間でやるには中々辛いものでした。
image.png

そのため、もっと簡単に集計結果を更新、閲覧できるようにしたいと思い、アンケート回答結果のDB化、集計結果閲覧用のWebページ作成に取り組みました。

目標

背景に書いたように、集計結果の作成、閲覧を楽にするということを目標に取り組みました。
集計結果閲覧用のWebページを作るまでにめんどくさいことがいくつかありましたが、最終的にはDBにはPostgreSQL、バックエンドにFastAPI、フロントエンドはReactを使ってます。

全体構成

作成した集計結果閲覧Webページの全体構成です。
image.png
全体の流れは、まず何はともあれ勉強会を開催します。
勉強会開催後にはMicrosoft Formsを利用して参加者にアンケート回答してもらいます。
回答結果はFormsからダウンロードし、回答データはFastAPIで作ったWebサーバを経由してPostgreSQLに保存します。保存したアンケートの回答データを基にしてReactで作成したWebページで集計結果を表示しています。最後に勉強会参加者はWebページで集計結果を閲覧してフィードバックを得る。
そして次の勉強会へ繋がる、という流れになります。

これによって辛い集計作業から解放され、DBにアンケート回答結果を保存するだけで集計結果を表示できるようになります。

作成した画面

アンケート結果をバーグラフで表示してくれるこんな画面を作成しました。

image.png

開発環境&使用したもの(言語、ライブラリなど)

  • OS
    • Windows10 Pro
  • DB
    • PostgreSQL|13.3
  • Backend
    • 言語:python|3.7.10
    • FW・ライブラリ
      • FastAPI|0.65.1
      • SQLAlchemy|1.3.3 
      • pydantic|1.8.2
      • pandas|1.3.0
      • uvicorn|0.13.1
  • Frontend
    • 言語:HTML, CSS, Typescript
    • FW・ライブラリ
      • React|17.0.2
      • Typescript|4.3.5
      • chart.js|3.5.0
      • react-chartjs-2|3.0.4
  • ツール関連
    • アンケートツール:Microsoft Forms
    • DBeaver:DBクライアントツール
    • IDE:Visual Studio Code

それではここから各項目について説明して言います。

アンケートデータ

勉強会後に実施しているアンケートはMicrosoft Formsで作成しており以下の質問項目を設定しています。

  • 質問1:勉強会の満足度を教えて下さい。
    • 回答:5段階評価(1 - 低評価、5 - 高評価)
  • 質問2:今回の内容を他の人にも紹介したいと思いますか?
    • 回答:5段階評価(1 - 思わない、5 - とてもそう思う)
  • 質問3:また勉強会に参加したいと思いますか?
    • 回答:5段階評価(1 - 思わない、5 - とてもそう思う)
  • 質問4:勉強会で発表したいと思いますか?
    • 回答:5段階評価(1 - 思わない、5 - とてもそう思う)
  • 質問5:勉強会で取り上げてほしいトピックを教えて下さい
    • 自由記入(未記入可)
  • 質問6:ご意見、ご感想などなんでも御記入下さい。
    • 自由記入(未記入可)

毎回同じアンケートに答えてもらいFormsには回答結果が溜まっていき、管理画面から集計結果の閲覧、データのダウンロードが可能です。
Formsの管理画面からでもそれなりの結果は見れるのですが、集計結果を共有するにもWebページ化するにも以下のように取り扱いしにくいところがありました。

  • 開催回の情報がないからいつの勉強会に対しての回答結果かわからない
  • ダウンロードできるデータはエクセル形式
  • 管理画面の結果は管理権限がある人しか見れない
  • などなど、、、

なのでデータダウンロードだけ手作業で行い、アンケートデータはDBへ保存することとしました。

DBへの保存リクエストはpythonで以下のように実装し、保存用データ(csv)を用意して実行します。
pandasでcsvファイルを読込んで1行データ毎に保存用データを作りpostリクエストを送ります。

post_request.py
def post_request_questionnaire(url: str, datafile: str):

  # 保存対象データ読込
  q_df = pd.read_csv("questionnaire.csv")

  for idx, q_item in q_df.iterrows():
    # 送信用データ
    req ={
      "id": "id",
      "satisfaction_level": q_item[1],
      "recommendation_level": q_item[2],
      "topics": q_item[3],
      "participation_level": q_item[4],
      "presentation_level": q_item[5],
      "free_comment": q_item[6],
      "holding_num": q_item[0]
    }

    # postリクエスト
    with requests.post(url, json=req) as response:
      res = response.json()
      print(res)

DB

アンケートデータの保存用にPostgreSQLでデータベースを定義しました。
PostgreSQLはここからインストーラをダウンロードして導入。

データベース定義はcreate databasecreate tableなどのコマンドで行うのもいいですが、大変なのでDBeaver使って行いました。
テーブル定義からちょっとしたデータのCRUDなどがUI操作で出来ます。

今回は簡単にテーブル1つだけのデータベースにしています。
定義したテーブルは以下のような感じです。(英語は苦手なので適当。。。)

テーブル名:questionnaire

カラム名 データタイプ 説明
id varchar 回答データID
satisfaction_level int 満足度
recommendation_level int 他の人にも紹介したいか
topics varchar 取り上げてほしいトピック
participation_level int また参加したいか
presentation_level int 発表したいと思ったか
free_comment varchar 自由記入コメント
holding_num int 開催回

そして、データベース定義と合わせてCRUD処理のスクリプトをPythonで実装してWebAPIで公開します。
詳細はバックエンドの説明で記載します。

バックエンド

DBへのCRUD処理をFastAPIでRestAPIにて公開します。

設計

バックエンド側の設計は以下の書籍を参考にしています。

構成は大きくRestAPIの定義部分(main, routers)とDB処理部分(DB)に分かれます
各機能項目の関連は下図の通りです。

RestAPI

mainはFastAPIの起動処理(インスタンス生成、routersの追加など)に関する処理で、
RestAPIの具体的な定義はrouters内のスクリプト(health, api)で3つのインターフェイスを実装しています。

  • GET| baseURL/health
    • ヘルスチェック(生存確認)用
  • GET| baseURL/api/questionnaire/{holding_num}
    • 指定開催回のアンケートデータの取得(0を指定した場合は全て)
  • POST|baseURL/api/questionnaire
    • 回答データの登録

また、FastAPIを使うと起動後にswaggerによるドキュメンテーションも公開されるので便利です。
baseURL/docsでアクセス可能で、以下のようなページを閲覧できます。

FastAPIの公式チュートリアルが参考になります。

apiで定義したインターフェイスでDBアクセスに関する処理をcrudsスクリプトから以下のように呼び出しています。

api.py
@router.get("/questionnaire/{holding_num}")
def questionnaire_by_holding_num(
  holding_num: int,
  db: Session = Depends(get_db)
):
  if(holding_num == 0):
    # 全データ取得
    return cruds.select_questionnaire_all(db=db)
  else:
    # 指定開催回のデータ取得
    return cruds.select_questionnaire_by_holding_num(db=db, holding_num=holding_num)

@router.post("/questionnaire") # POST
def add_questionnaire(
  questionnaire: schemas.Questionnaire,
  db: Session = Depends(get_db)
):
  return cruds.add_questionnaire_report(
    ## questionnaireテーブルへ追加するデータを設定
  )

CRUD処理

構成図のDBの箇所に該当して、ORMライブラリ(SQLAlchemy)を使ってデータベース接続、CRUD、モデル定義を、pydanticでSchemaを定義しています。そして初期化機能を追加して構成しています。
それぞれの役割を解説します。

database

DB接続に関する機能を定義しておりSQLAlchemyを使ってDBエンジンの作成、DBアクセス時のセッションの作成を行っています。

DB接続に必要な設定は設定ファイル(database.ini)用意して読み込んで、sqlalchemy.create_engineを使ってDBエンジンを作成しています。
引数には接続対象となるDBのアドレスを指定します。

database.py
db_config = read_config() # 設定ファイル(database.ini)の読み込み
hostname  = db_config["host"]
port      = db_config["port"] 
db_name   = db_config["database"]
user      = db_config["user"]
password  = db_config["password"]
server_url = f"postgresql://{hostname}:{port}/{db_name}?user={user}&password={password}"
engine = create_engine( server_url, encoding = "utf-8", pool_recycle=3600, echo=False )

 セッションの作成にはsqlalchemy.orm.sessionmakerを使い、セッション取得のためにget_db()関数を用意しています。

database.py
# SessionMaker作成
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

# セッションの取得
def get_db():
  db = SessionLocal()
  try:
    yield db
  except:
    db.rollback()
    raise
  finally:
    db.close()

schemas

DBに登録するデータの構造をQuestionnaireクラスとして定義しておきます。

schemas.py
class Questionnaire(BaseModel):
  id: str
  satisfaction_level: int
  recommendation_level: int
  topics: Optional[str]
  participation_level: int
  presentation_level: int
  free_comment: Optional[str]
  holding_num: int

  class Config:
    orm_mode = True

model

マッピングするテーブルのカラム設定をquestionnaireクラスとして定義します

schemas.py
class questionnaire(Base):
  __tablename__ = "questionnaire"

  id = Column(
    String(255),
    primary_key=True,
    nullable = False,
    comment="主キー"
  )

  satisfaction_level = Column(
    Integer,
    nullable=False,
    comment="満足度"
  )

 ## ... 以降、他のカラムも上記と同様に実装する

cruds

DBへのクエリ発行処理を実装するクラスです。
ここまでで定義した、database, model、schemasを使ってCRUD処理を実装していきます。
今回はアンケートデータの取得、登録の2つの処理を実装していて、database.pyのget_db()にて取得されたセッションを受け取る、queryメソッドでDBへのクエリを発行という流れです。
実装内容の一部を以下に示します。

アンケートデータ取得
データ取得はアンケートデータ全て取得と指定した開催回分を取得の2種類の処理を実装しています。
開催回を指定する場合にはfilterメソッドを使ってデータ取得にフィルタをかけています。

cruds.py
# アンケートデータを全て取得
def select_questionnaire_all(db: Session)->List[schemas.Questionnaire]:
  return db.query(model.questionnaire).all()

# 指定した開催回数のアンケートデータを取得
def select_questionnaire_by_holding_num(
  db: Session,
  holding_num: int
  )->List[schemas.Questionnaire]:
  return db.query(model.questionnaire).filter(model.questionnaire.holding_num == holding_num).all()

アンケートデータ登録
データ登録は新規データを作成してSessionのaddメソッドを実行します。
そしてcommitメソッドで更新のコミットをトランザクションを完了させます。

cruds.py
def add_questionnaire_report(
  db: Session,
  satisfaction_level: int,
  recommendation_level: int,
  topics: str,
  participation_level: int, 
  presentation_level: int,
  free_comment: str,
  holding_num: int,
  commit: bool = True
  )->schemas.Questionnaire:

  questionnaire_id = str(uuid.uuid4())[:6]
  data = model.questionnaire(
    id = questionnaire_id,
  # 引数の変数を設定 長いので省略...
  )

  # データ追加
  db.add(data)

  if commit:
   # コミット実行(データ登録が確定する)
    db.commit()
    db.refresh(data)

  return data

サーバ起動

uvicornを使って以下のコマンドで起動します。

$ uvicorn main:app --reload

ここはfastapiの公式サイトに示されている方法を参考しています。

フロントエンド

バックエンドからアンケートデータを取得してアンケート集計結果を表示します。
カテゴリカルデータはバーグラフで、トピックと自由回答コメントはテーブルでそのまま表示しています。

画面構成

画面構成は下図の通りで、「5段階評価」、「取り上げて欲しいトピック」、「自由回答コメント」の3ページをタブで切り替えできるようにしています。

image.png
グラフ表示にはChart.js、タブやテーブル等にはMaterial-UIを使っています。

設計&実装

バックエンドからデータを取得してグラフ表示するまでの処理を実装しますが、全体をいくつかの機能に分解して下図のように構成しました。

それぞれの役割を簡単に説明します。

Questionnaire

アンケートの回答データを表すクラスです。
アンケート回答項目を表すメンバ変数を持っていて、このクラスへアクセスすることで各項目の回答結果を参照できます。

QuestionnaireDataset

個別の回答結果が集まってアンケートデータ全体を表しています。
QuestionnaireDatasetクラスでは開催回をkeyとした連想配列にQuestionnaireクラスをリストで保持しています。
そして、データセットに対しての操作(データ全取得、開催回単位でのデータ取得、データ追加など)を公開しています。

Gateway

バックエンド(FastAPIで公開されるRestAPI)に対してリクエストを送る機能を持つクラスです。
ここではバックエンドとのやり取りにのみ責任があり、取得できたデータを何も処理せずに返しています。
実装は以下のようにパラメータ指定してfetchAPIを呼び出す程度です。

Gateway.ts
  public fetch( endpoint: string, data: {} = {}, method: string = 'GET' ): Promise<Array<any>>{
    const url = this.baseUrl_ + endpoint;

    return fetch(url, {
      method: method,
      headers: {
        'Content-Type': 'application/json',
      },
      mode: 'cors',
    })
    .then(response => {
      return response.json();
    })
  }

DataLoader

バックエンドからのデータ取得を行う処理の実行部分となります。
Gatewayクラスを使ってバックエンドからアンケートの回答データを取得して、QuestionnaireDatasetを作成して返します。

DataLoader.ts
  public fetchQuestionnaireDataAll(noCache: boolean = false): Promise<QuestionnaireDataset> {
    // return cache
    if ( !noCache && this.questionnaireDataCache_ ) {
      return Promise.resolve( this.questionnaireDataCache_ );
    }

    // fetch data
    return this.gateway_.fetch("/api/questionnaire/0")
      .then( (response: any) => {
        const dataset = new QuestionnaireDataset();
        // アンケートデータセットの作成
        response.forEach( (item: any) => {
          const q = new Questionnaire( /*省略*/ );
          dataset.add(item.holding_num, q);
        });

        this.questionnaireDataCache_ = dataset;         

        return dataset;
      });
  }

Presenter

DataLoaderを使って取得したアンケートデータとViewで使うChart.jsに入力するデータ構造とは異なっており、そのまま返却すると画面表示の役割を担うViewの部分でデータ構造の変換処理を行うことになります。
Viewでは画面表示に関する処理だけにしたいため、データ構造の変換を担う役割としてPresenterクラスを用意しました。

Controller

バックエンドからデータ取得は上記の通りいくつかの機能に分かれています。
Controllerはそれらの機能を組み合わせて最終的にアンケートデータ取得処理として公開しています。
アンケートデータ取得処理ではDataLoader、Gatewayの機能を使ってバックエンドからデータを取得し、取得したデータはPresenterを使ってViewで必要なデータ構造に変換して結果を返します。

controller.ts
  /**コメントは省略*/
  constructor(){
    const gateway = new Gateway("http://localhost", 8000, 1000);
    this.dataloader_ = new DataLoader(gateway);
    this.presenter_ = new Presenter();
  }

  /**コメントは省略*/
  public fetchQuestionnaireDataAll(): Promise<GraphDataset> {
    return this.dataloader_.fetchQuestionnaireDataAll(true)
      .then( (questionnaireDataset: QuestionnaireDataset) => {
        const graphDataset = this.presenter_.makeGraphDataset(questionnaireDataset.getAll())
        return graphDataset;
      });
  }

View

Reactの機能を使って画面表示に関する処理を行います。
画面遷移や画面操作時のイベントハンドラ等の処理も実装します。
アンケートデータの取得が必要な時はControllerの機能を呼び出してデータ取得を行います。

【View】コンポーネント構成

アンケート集計結果表示画面はページとしては一つですが各部品にコンポーネントを分割して構成しています。

  • page
    • Home.tsx : アンケート集計ページ全体
  • components
    • BarGraph.tsx : バーグラフ単体
    • TabPanel.tsx : タブを選択した際に表示するパネル領域を表す
    • GradeEvalGraphPanel.tsx : 5段階評価タブを選択したときに表示するパネル
    • TopicsPanel.tsx : 取り上げて欲しいトピックタブを選択したときに表示するパネル
    • FreeCommentPanel.tsx : 自由回答コメントタブを選択したときに表示するパネル

各コンポーネントの関係は下図のようになっています。

1. Homeコンポーネント

Homeコンポーネントはアンケート集計ページ全体を表していて、タブコンポーネントによって各パネルの表示状態を切り替えています。
タブコンポーネント(Tab, Tabs)はMaterial-UIのコンポーネントを使っており、TabPanelコンポーネントはMaterial-UIのサンプルを参考に作成しました。

Home.tsx
  render() {
    return (
      <div>
        <div>
          <h1>S2Sアンケート集計結果</h1>
        </div>
        <div>
          <Paper>
            <Tabs
              value={this.state.tabs.value}
              indicatorColor="primary"
              textColor="primary"
              centered
              onChange={this.handleChange}
            >
              <Tab label="5段階評価" value={0} />
              <Tab label="取り上げてほしいトピック" value={1} />
              <Tab label="自由回答コメント" value={2} />
            </Tabs>
            <TabPanel value={this.state.tabs.value} index={0} >
              <GradeEvalGraphPanel />
            </TabPanel>
            <TabPanel value={this.state.tabs.value} index={1} >
              <TopicsPanel /> 
            </TabPanel>
            <TabPanel value={this.state.tabs.value} index={2} >
              <FreeCommentPanel />
            </TabPanel>
          </Paper>
        </div>
      </div>
    );
 }
}

上記のようにTabsコンポーネントを配置して、その子要素としてTabコンポーネントを配置しています。
Tabコンポーネントはlabel、valueというプロパティがありlabelはタブにされるラベルの値で、valueはタブのインデックス値を表しています。
TabPanelコンポーネントも同様にvalueプロパティを持っていて、TabコンポーネントのonChangeイベントのイベントハンドラでstateの値を更新してその値を設定しています。あと、TabPanelコンポーネント内ではindexプロパティとvalueプロパティの値が同じだったら表示状態を有効、違っていたら非表示となる制御があり、これによってタブを選択したら対象のページが表示されるという仕組みになっています。

2. GradeEvalGraphPanel、BarGraphコンポーネント

GradeEvalGraphPanelコンポーネントはBarGraphコンポーネントを使ってアンケートデータをグラフ表示しており、コンポーネント表示時とドロップダウンリストで開催回選択時にControllerを使ってアンケートデータを取得します。
取得したアンケートデータはBarGraphコンポーネントのgraphDataプロパティへ設定してバーグラフを表示しています。graphDataプロパティに設定するデータはstate.datasetsに設定しており、Presenterでデータタイプ毎に連想配列で格納しています。

GradeEvalGraphPanel.tsx

  render(){

    return (
      <div>
        <div>
          開催回 :  
          <span>
            <select onChange={this.onChangeSelection}>
              { 
                this.state.holdings.map((holding_num, index) => {
                    let item_label = holding_num.toString()
                    if (holding_num === 0) {
                      item_label = "全て"
                    }
                    return <option value={ holding_num } key={index}>{ item_label }</option>
                  })
              }
            </select>
          </span>
        </div>
        <div>
          <h2>勉強会の満足度を教えてください</h2>
          <BarGraph graphData={{labels: this.labels, datasets: this.state.datasets["satisfaction"]}} />
        </div>
        <div>
          <h2>他の人にも今回の発表内容を紹介したいと思いますか</h2>
          <BarGraph graphData={{labels: this.labels, datasets: this.state.datasets["recommendation"]}} />
        </div>
        <div>
          <h2>また参加したいと思いましたか</h2>
          <BarGraph graphData={{labels: this.labels, datasets: this.state.datasets["participation"]}} />
        </div>
        <div>
          <h2>発表してみたいと思いましたか</h2>
          <BarGraph graphData={{labels: this.labels, datasets: this.state.datasets["presentation"]}} />
        </div>
      </div>
   );

  }
3. TopicsPanel、FreeCommentPanelコンポーネント

GradeEvalGraphPanelコンポーネントと同様にコンポーネント表示時にControllerを使ってアンケートデータを取得して取り上げて欲しいトピック、自由回答コメントの情報を取得してMaterial-UIのTableコンポーネントを使って表示しています。

TopicsPanel.tsz
  render() {
    return (
      <div>
        <TableContainer component={Paper}>
          <Table area-label="topics table">
            <TableHead>
              <TableRow>
                <TableCell>開催回</TableCell>
                <TableCell>取り上げてほしいトピック</TableCell>
              </TableRow>
            </TableHead>
            <TableBody>
              {
                Object.keys(this.state.topics).map((key: string) => {
                  let row_num = 0
                  return this.state.topics[key].map((topic: string) => {
                    return (
                      <TableRow key={row_num++}>
                        <TableCell>{key}</TableCell>
                        <TableCell>{topic}</TableCell>
                      </TableRow>
                    );
                  });
                })
              }
            </TableBody>
          </Table>
        </TableContainer>
      </div>
    );
  }
}

Tabaleコンポーネントでテーブル全体を表していて、TableHeadで各列のタイトル部分TableRowで1行分のデータ、TableCellで1つのセルを表しています

これで、バックエンドからデータを取得してアンケートの集計結果をWebページ上で確認することができました。
データ集計を手作業でする必要はなくなり、DBへアンケート回答結果を登録するだけで良くなりました。

うれしい!!!!

おわりに

Formsから取得したデータをDBに登録してFastAPIで公開、Reactを使ってWebアプリケーションとして表示するまでを紹介させて頂きました。
エラー処理はほとんどやってないことや、テストも作ってない、などなど出来ていないことはまだまだあるので今後もちょっとずつバージョンアップしていければと思います。

何かデータを可視化したいと思っている方のご参考になれば嬉しいです。
ソースコードはGithubにて公開しているので自由にご確認下さい。

長くなってしまいましたが、ここまで読んでくださりありがとうございました。

ソースコード

  • フロントエンド

  • バックエンド

参考資料

  • 書籍

  • 公式サイト

5
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
6