未熟学生エンジニアのブログ

プログラミング・Web開発をする大学院生のブログ

SPA・サーバレスハンズオン part3 React/Firebase Cloud Firestoreでチャットアプリを作る

シリーズ

前提

  • part1part2をやっている
  • html,javascriptを一度でも書いたことがある
  • プログラミングの「クラス」と「継承」を使ったことがある
  • Firebaseに登録している

今回できるようになること

  • Reactの基本の流れがわかる
  • Reactのstate管理の方法がわかる
  • Firestoreと連携してデータベースを使ったチャットを作れる
  • 次にやるべきことがわかる

チャットアプリを作る

今回はチャットアプリを作ります。

f:id:swiftfe:20190501071018p:plain

f:id:swiftfe:20190501073412p:plain

今回のアプリは以下の記事のFirestore版のようなものになります。

サンプル

デモページ: https://huit-tetsufe-0506.firebaseapp.com/ ソースコード: https://github.com/TetsuFe/huit_react_handson_20190506

参考

FirebaseのFirestoreを使うための初期設定をする

FirebaseにはHosting以外にもいろんな便利機能があります。

Cloud Firestore(Firestore)は、Firebaseのデータベースサービスで、web上にデータを保存したり、取得したりするのに使えます。

Firestoreの立ち位置としては以下の図のようになります。

f:id:swiftfe:20190501120118p:plain

f:id:swiftfe:20190501120121p:plain

さて、Hostingの際と同じように、Firebaseのセットアップをしましょう。

Firebaseのコンソール画面から、Firestoreを選択します。

f:id:swiftfe:20190429132020p:plain

f:id:swiftfe:20190429132026p:plain

f:id:swiftfe:20190429132031p:plain

これでひとまず初期設定は完了です。

今回はテストモードとして誰でもデータの改変が行えるようにしましたが、これは非常に危険です。Firestore側での認証設定やセキュリティルールは適切に設定しておかないとデータの改変が自由に行えてしまいます。(今回のように重要なデータでない場合は問題ありません)

reactページ側からFirestoreにアクセスするためのモジュールをインストールします。

$ npm install firebase --save

FirestoreにアクセスするためにはapiKeyなどの情報が必要です。

Firebaseの画面から、APIキーとprojectidをコピーします。これはサイトに埋め込んでも大丈夫です。

参考:Firebase apiKey ってさらしていいの? ほんとに? - Qiita

f:id:swiftfe:20190429133406p:plain

f:id:swiftfe:20190429132949p:plain

上の例では、以下のようにファイルを作成します。(値は全員少し違うはずです)

firebaseフォルダを作り、その中にconfig.jsを作りましょう。

firebase/config.js

export const firebaseConfig = {
    apiKey: 'AIzaSyBTXINX3dCCSCQcwypUxk7wqRiEYbxqgw0',
    projectId: 'huit-tetsufe-0506'
};

フォルダの構成は以下のようになっているはずです。

huit_handson_20190506
├── firebase
│   └── config.js

さらに、firebase/index.js というファイルを作り、データベースを操作するためのfirebaseDbというインスタンスを他のファイルでも使い回せるようにします。

firebase/index.js

import firebase from 'firebase';
import { firebaseConfig } from './config.js';

export const firebaseApp = firebase.initializeApp(firebaseConfig);
export const firebaseDb = firebaseApp.firestore();

フォルダの構成は以下のようになっているはずです。

huit_handson_20190506
├── firebase
│   └── config.js
│   └── index.js

Reactとコンポーネント

Reactは、コンポーネントという単位で表示する要素を小さなモジュール(ファイル)に分割することを推奨しています。

要は要素ごとに細かく分けるだけです。難しいことではありません。分ける単位もとりあえずは適当でいいでしょう。

コンポーネントを作る前に、srcフォルダの中に、componentsフォルダを作成しましょう。

componentsというフォルダ名に意味はありません。なぜ作る必要があるのでしょうか?

単にわかりやすいようにフォルダわけしただけです。ただ、これは地味に重要なことです。画面が複雑になればファイル数も多くなり、ファイルはどんどん見つけにくくなるからです。複雑になっていけば、componentsの中にさらにフォルダを作るなどする必要が出てくると思います。

src/components/Message.jsというファイルを作りましょう。

import React from "react";

export default class Message extends React.Component {
    render() {
        return (
            <div className="Message">
                <img className="" src={this.props.message.profile_image} />
                <div className="">
                    <p className="">@{this.props.message.user_name}</p>
                    <p className="">{this.props.message.text}</p>
                </div>
            </div>
        );
    }
}

さらに、src/components/ChatForm.jsというファイルを作りましょう。

import React from "react";

export default class ChatForm extends React.Component {
    render() {
        return (
            <div className="ChatForm">
                <div className="">
                    <input name='user_name' onChange={this.props.onTextChange} className="" placeholder="名前" />
                    <input name='profile_image' onChange={this.props.onTextChange} className="" placeholder="プロフィール画像URL" />
                </div>

                <textarea name='text' className="" onChange={this.props.onTextChange} />
                <button className="" onClick={this.props.onButtonClick}>送信</button>
            </div>
        );
    }
}

こんな感じの構造になっているはずです

$ tree src
src
├── App.css
├── App.js
├── App.test.js
├── components
│   ├── ChatForm.js
│   └── Message.js
├── index.css
├── index.js
├── logo.svg
└── serviceWorker.js

src/App.jsを編集して、MessageコンポーネントとChatFormコンポーネントを使いましょう。

import React from 'react';
import './App.css';
import { firebaseDb } from './firebase/index.js'
import Message from './components/Message.js'
import ChatForm from './components/ChatForm.js'

const messages = firebaseDb.collection('messages')

class App extends React.Component {
  constructor(props) {
    super(props);
    this.onTextChange = this.onTextChange.bind(this)
    this.onButtonClick = this.onButtonClick.bind(this)
    this.state = {
      text: "",
      user_name: "",
      profile_image: "",
      messages: []
    }
  }

  componentDidMount() {
    messages.onSnapshot
      ((querySnapshot) => {
        // クエリが非同期処理のため、この中にsetStateなどを書かないと空になってしまう
        let msgs = []
        querySnapshot.forEach((doc) => {
          // 新しい順に取得される
          const m = doc.data();
          msgs.push({
            'text': m.text,
            'user_name': m.user_name,
            'profile_image': m.profile_image,
          })
        });
        this.setState({
          messages: msgs
        });
      });
  }

  onTextChange(e) {
    if (e.target.name == 'user_name') {
      this.setState({
        "user_name": e.target.value,
      });
    } else if (e.target.name == 'profile_image') {
      this.setState({
        "profile_image": e.target.value,
      });
    } else if (e.target.name == 'text') {
      this.setState({
        "text": e.target.value,
      });
    }
  }

  onButtonClick() {
    // 簡単なバリデーション
    if (this.state.user_name == "") {
      alert('user_name empty')
      return
    } else if (this.state.text == "") {
      alert('text empty')
      return
    }
    messages.add({
      "user_name": this.state.user_name,
      "profile_image": this.state.profile_image,
      "text": this.state.text,
    })
      .then(function (docRef) {
        console.log("Document written with ID: ", docRef.id);
      })
      .catch(function (error) {
        console.error("Error adding document: ", error);
      });
  }

  render() {
    return (
      <div className="App">
        <div className="App-header">
          <h2>Chat</h2>
        </div>
        <div className="MessageList">
          {this.state.messages.map((m, i) => {
            return <Message key={i} message={m} />
          })}
        </div>
        <ChatForm onTextChange={this.onTextChange} onButtonClick={this.onButtonClick} />
      </div>
    );
  }
}

export default App;

Reactのコードを読み解く

ここからはコードの解説に入ります。

src/App.js を見てください。src/App.jsでは、Appというクラスが宣言されています。今回の場合はこれだけです。ここからはAppクラスについて解説します。

まず、Appクラスですが、これはReact.Componentというクラスを継承する形で宣言されています。

import React from 'react';
// 省略

class App extends React.Component {
// 省略
}

React.Componentを継承する理由とはなんでしょうか。どういう意味を持っているかこれから説明します。

React.Componentを継承すると起こること

  • 1.<App/>などと書くことでReactのコンポーネントとして好きな場所に埋め込むことができる
  • 2.ライフサイクルメソッドを自動的にいいタイミングで実行してくれる
    • componentDidMound()
    • render()
    • その他

1. <App/>などと書くことでReactのコンポーネントとして好きな場所に埋め込むことができる

src/index.jsで、<App/>として使われています。React.Componentを継承すると、Reactのコンポーネントとして扱うことができます。これはとりあえずそういうルールなのだと覚えましょう。(おまじないが嫌い?ならばこれを読みなさい。React.Component – React

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
// 省略

ReactDOM.render(<App />, document.getElementById('root'));

/// 省略
serviceWorker.unregister();

「好きな場所に埋め込むことができる」とはどういうことでしょうか。これはAppクラスのrender()内を見ると意味がわかります。

以下の<ChatFrom>もReact.Componentを継承しています。(src/components/ChatForm.jsを見ればわかります)

src/App.js

// 省略
class App extends React.Component {
  // 省略
  render() {
    return (
      <div className="App">
        <div className="App-header">
          <h2>Chat</h2>
        </div>
        <div className="MessageList">
          {this.state.messages.map((m, i) => {
            return <Message key={i} message={m} />
          })}
        </div>
        <ChatForm onTextChange={this.onTextChange} onButtonClick={this.onButtonClick} />
      </div>
    );
  }
}

以上のように書くことで、<ChatFrom><div className="App-header">よりも下になるように配置できます。

f:id:swiftfe:20190501034849p:plain

2. ライフサイクルメソッドを自動的にいいタイミングで実行してくれる

1はまあそういうものか、という感じだと思ってもらえればOKです。2の方が重要です。

React.Component を継承している Appクラスには、componentDidMound()などの、あるタイミングで自動的に実行されるメソッドがあります。

これらの あるタイミングで自動的に実行されるメソッド群を、ライフサイクルメソッドといいます。

モバイルアプリやフレームワークを使ったフロントエンド開発では同じような概念が出てくるので、ここでライフサイクルメソッドというものの存在に慣れておくとReact以外を使う際に対応しやすいです。

Reactのライフサイクルメソッドの流れは、以下のようになります。

https://github.com/iktakahiro/react-component-lifecycle-diagram/blob/master/react-v16.2/react-component-lifecycle.png?raw=true

引用元:GitHub - iktakahiro/react-component-lifecycle-diagram

初期化とマウントに関するライフサイクルメソッド

参考:React コンポーネントのライフサイクルイベント その1 - React 入門

Appの初期化の際、まず最初に自動的に実行されるのがconstructor(props)というメソッドになります。

constructor(props)は、Appが作成されたときに一回だけ実行されます。このメソッドでは、this.stateなどの変数の初期化を主に行います。

src/App.js

class App extends React.Component {
  constructor(props) {
    super(props);
    this.onTextChange = this.onTextChange.bind(this)
    this.onButtonClick = this.onButtonClick.bind(this)
    this.state = {
      text: "",
      user_name: "",
      profile_image: "",
      messages: []
    }
  }
  // 省略
}

次がrender()です。render()は返り値を持ちます。returnで値を返していることに気をつけてください。

render()は、htmlを模したjsxという記法で書かれたオブジェクトを返します。つまり、これはhtmlではありません。jsxという独特の記法で、これはjavascriptのオブジェクトです。<div className="App">は、htmlではありません。javascriptです。

class App extends React.Component {
  // 省略
  render() {
    return (
      <div className="App">
        <div className="App-header">
          <h2>Chat</h2>
        </div>
        <div className="MessageList">
          {this.state.messages.map((m, i) => {
            return <Message key={i} message={m} />
          })}
        </div>
        <ChatForm onTextChange={this.onTextChange} onButtonClick={this.onButtonClick} />
      </div>
    );
  }
}

とはいっても、とりあえずは以下のように書けばよいと考えていればOKです。

class App extends React.Component {
  // 省略
  render() {
        return (
          // ここにhtmlっぽいjsxというものを書く
        );
  }
}

jsxの書き方は、色々書いてみてなれるしかないと思います・・

次がcomponentDidMount()です。今回は、Firestoreからのデータ取得と、React内のthis.stateの更新をしています。(実際には、データが更新された際にその処理をするという意味になります)

src/App.js

class App extends React.Component {
  // 省略
  componentDidMount() {
    messages.onSnapshot
      ((querySnapshot) => {
        // クエリが非同期処理のため、この中にsetStateなどを書かないと空になってしまう
        let msgs = []
        querySnapshot.forEach((doc) => {
          // 新しい順に取得される
          const m = doc.data();
          msgs.push({
            'text': m.text,
            'user_name': m.user_name,
            'profile_image': m.profile_image,
          })
        });
        this.setState({
          messages: msgs
        });
      });
  }
  // 省略
}

Reactの状態管理:setStateとライフサイクルメソッド

ここまででAppクラスの初期化の際の流れを知ることができたと思います。ここからは、ReactのsetStateとライフサイクルメソッドの関係について説明します。

Reactを使ったWebアプリでは、ユーザのアクションやDBのデータの変更に応じて変数(状態)を更新し、画面(ページ)を適宜書き換えるということを行います。例えば、チャット中に新しい投稿があった時、それを画面に反映するという場合をイメージしてください。

このような場合に使うのが、setStateメソッドです。

this.setState({
      messages: msgs
});

先ほどの図を再掲します。

https://github.com/iktakahiro/react-component-lifecycle-diagram/blob/master/react-v16.2/react-component-lifecycle.png?raw=true

引用元:GitHub - iktakahiro/react-component-lifecycle-diagram

ここで、setStateを実行した時、図の中央の黒丸の「state changed」が起こり、ライフサイクルメソッドが順に実行されます。図中ではshouldComponentUpdateなどのメソッドがありますが、今回はrender()だけ考慮すればOKです。

簡潔に言えば、setStateは、変数(状態)を変更した上で、render()などのライフサイクルメソッドを起動し、画面を更新するためのメソッドです。

src/App.jsをみてみましょう。

ここでは、チャットのDBデータが更新されたとき、データに合わせて画面を更新するという例を考えます。

  1. setStateは、firestoreから受け取ったデータmsgsを使ってthis.state.messagesを更新
  2. その後、setStateに反応したライフサイクルメソッドrender()が自動的に実行され、更新されたthis.state.messagesを使ってメッセージリストを画面に表示することができる

setStateはcomponentDidMount()内で実行されているように見えますが、実際にはcomponentDidMount()が呼ばれたタイミングとsetStateが実行されるタイミングは違う(ややこしいですが)のに注意してください。

class App extends React.Component {
  // 省略
  componentDidMount() {
    messages.onSnapshot
      ((querySnapshot) => {
        // クエリが非同期処理のため、この中にsetStateなどを書かないと空になってしまう
        let msgs = []
        querySnapshot.forEach((doc) => {
          // 新しい順に取得される
          const m = doc.data();
          msgs.push({
            'text': m.text,
            'user_name': m.user_name,
            'profile_image': m.profile_image,
          })
        });
        this.setState({
          messages: msgs
        });
      });
  }
  // 省略
  render() {
    return (
      <div className="App">
        <div className="App-header">
          <h2>Chat</h2>
        </div>
        <div className="MessageList">
          {this.state.messages.map((m, i) => {
            return <Message key={i} message={m} />
          })}
        </div>
        <ChatForm onTextChange={this.onTextChange} onButtonClick={this.onButtonClick} />
      </div>
    );
  }
}

Firestoreに保存するデータ構造

Firestoreに保存するデータ構造としては、messagesという箱(FirestoreではCollectionといいます)を用意して、その中にメッセージを追加していく形になります。以下がデータの例になります。

{
    "messages": [
        {
            "1": {
                "profile_image": "https://pbs.twimg.com/profile_images/1064542710327963648/eN983mCr_400x400.jpg",
                "text": "こんにちは",
                "user_name": "僕"
            }
        },
        {
            "2": {
                "profile_image": "http://hyohyolibrary.com/wp-content/uploads/2018/07/oregairuzoku9thumbnail.png",
                "text": "やっはろー",
                "user_name": "由比ヶ浜結衣"
            }
        }
    ]
}

Firestoreのaddメソッドを使うことで投稿処理を実装します。(注意:addメソッドの場合、新しいデータはcollectionの中で雑な順番で保存されます。全てのメッセージを一気に取得する際、順番はaddした順にはならないということです。チャットなどの場合、上から新しい順に表示するか上から古い順に表示するかという順番は重要です。例えばLINEの場合は上から古い順に表示ですよね。order_byを使うなどすることで、この問題は解決できます。https://firebase.google.com/docs/firestore/query-data/order-limit-data?hl=ja

src/App.js

  onButtonClick() {
    // 簡単なバリデーション
    if (this.state.user_name == "") {
      alert('user_name empty')
      return
    } else if (this.state.text == "") {
      alert('text empty')
      return
    }
    messages.add({
      "user_name": this.state.user_name,
      "profile_image": this.state.profile_image,
      "text": this.state.text,
    })
      .then(function (docRef) {
        console.log("Document written with ID: ", docRef.id);
      })
      .catch(function (error) {
        console.error("Error adding document: ", error);
      });
  }

フォームのボタンを押したとき、onButtonClicked()が呼ばれて(関数が実行されることを関数が呼ばれるという言い方をします)、onButtonClicked()の中でFirestoreのaddメソッドによってDBにデータを追加します。

DBデータの変更に応じたリアルタイム更新

チャットの場合、相手からの返事が来たときに自動的にページに反映されると嬉しいですよね。Firestoreには、それを実現できる機能があります!

FirestoreではonSnapshotメソッドを使うことで、DB上のデータがアップデートされたとき、自動的に接続中のクライアントのデータにも反映することができます。(getメソッドではできません)

ちなみに内部ではwebsocket通信をしているようです

参考:https://firebase.google.com/docs/firestore/query-data/listen?hl=ja

src/App.js

class App extends React.Component {
  // 省略
  componentDidMount() {
    messages.onSnapshot
      ((querySnapshot) => {
        // クエリが非同期処理のため、この中にsetStateなどを書かないと空になってしまう
        let msgs = []
        querySnapshot.forEach((doc) => {
          // 新しい順に取得される
          const m = doc.data();
          msgs.push({
            'text': m.text,
            'user_name': m.user_name,
            'profile_image': m.profile_image,
          })
        });
        this.setState({
          messages: msgs
        });
      });
  }
  // 省略
}

stateとprops

参考:React における State と Props の違い - Qiita

最後に、stateとpropsについて説明します。といっても、以下の2点を覚えておけばとりあえず問題ありません。

  • state: クラス内でsetStateを使って変更・更新したい変数として使う(更新される)
  • props: 他のコンポーネント(親コンポーネント)から受け取った変数(更新されない)

またsrc/App.jsの例をみてみます。

class App extends React.Component {
  constructor(props) {
    super(props);
    this.onTextChange = this.onTextChange.bind(this)
    this.onButtonClick = this.onButtonClick.bind(this)
    this.state = {
      text: "",
      user_name: "",
      profile_image: "",
      messages: []
    }
  }

  componentDidMount() {
    messages.onSnapshot
      ((querySnapshot) => {
        // クエリが非同期処理のため、この中にsetStateなどを書かないと空になってしまう
        let msgs = []
        querySnapshot.forEach((doc) => {
          // 新しい順に取得される
          const m = doc.data();
          msgs.push({
            'text': m.text,
            'user_name': m.user_name,
            'profile_image': m.profile_image,
          })
        });
        this.setState({
          messages: msgs
        });
      });
  }

  render() {
    return (
      <div className="App">
        <div className="App-header">
          <h2>Chat</h2>
        </div>
        <div className="MessageList">
          {this.state.messages.map((m, i) => {
            return <Message key={i} message={m} />
          })}
        </div>
        <ChatForm onTextChange={this.onTextChange} onButtonClick={this.onButtonClick} />
      </div>
    );
  }
}

ここで、stateとして使われているのはtext, user_name, profile_image, messagesです。this.state.text, this.state.user_nameのようにアクセスすることができます。これら変数の値はこのクラス内で更新されます。そのため、このクラス内でthis.stateとして宣言します。これがstateです。

constructor(props) {
    super(props);
    this.onTextChange = this.onTextChange.bind(this)
    this.onButtonClick = this.onButtonClick.bind(this)
    this.state = {
      text: "",
      user_name: "",
      profile_image: "",
      messages: []
    }
  }

一方で、propsは、他のコンポーネントから受け取った変数のことを指します。もっというと、渡す側はstateとして扱っている変数が、渡された側ではpropsになります。

            <div className="MessageList">
              {this.state.messages.map((m, i) => {
                return <Message key={i} message={m} />
              })}
            </div>

この3行目のmessage={m}という部分で、変数の受け渡しが起こっています。Messageコンポーネント側から見ると、messageというpropsを受け取っています。

7行目をみてください。this.props.messageという形で使っていることがわかると思います。

src/components/Message.js

import React from "react";

export default class Message extends React.Component {
    render() {
        return (
            <div className="Message">
                <img className="" src={this.props.message.profile_image} />
                <div className="">
                    <p className="">@{this.props.message.user_name}</p>
                    <p className="">{this.props.message.text}</p>
                </div>
            </div>
        );
    }
}

propsとstateを分ける意味

propsはあくまで受け取るだけで、その値は受け取ってから変更されることがありません。これは、コードを読むときに変更されうる値かどうかがわかりやすくなるという効果があります。言い換えれば、そのコンポーネント内にpropsを更新する処理が書かれていないことを示します。propsの更新にバグがあると感じたときは、親コンポーネントを見ればいい、ということに瞬時に気づくことができます。

Firebase Hostingを使ってデプロイ(web公開)する

せっかくチャットを作ったので、友達や家族とチャットができるようにweb上に公開しましょう!

前提:Firebase Hostingの初期設定をpart1でやっていること

ターミナルを開いて、以下を実行します。($は省略してください。)

$ npm run build
$ firebase deploy --only hosting

これでみんなとチャットができます!

おまけ:CSSを書いてみる

ここまでのサンプルコードはかなりひどい見た目になっていると思います。

f:id:swiftfe:20190501071018p:plain

これをまともな見た目にして、恥ずかしくないようにします。

参考

src/components/Message.js

import React from "react";

export default class Message extends React.Component {

    render() {
        const style = {
            "item": {
                display: "flex",
                alignItems: "flex-start",
                marginBottom: "0.8em",
            },
            "itemImage": {
                width: "100px",
                height: "100px",
                objectFit: "cover",
                borderRadius: "20px",
            },
            "itemName": {
                textAlign: "left",
                fontSize: "75%"
            },
            "itemMessage": {
                position: "relative",
                display: "inline-block",
                padding: "0.8em",
                background: "#deefe8",
                borderRadius: "4px",
                lineHeight: "1.2em",
            }
        }

        return (
            <div className="Message" style={style.item}>
                <img style={style.itemImage} src={this.props.message.profile_image} />
                <div className="">
                    <p style={style.itemName}>@{this.props.message.user_name}</p>
                    <p style={style.itemMessage}>{this.props.message.text}</p>
                </div>
            </div>
        );
    }
}

f:id:swiftfe:20190501073307p:plain

「chat」という文字がデカすぎるので、なんとかします。

src/App.css

.App {
  text-align: center;
}

.App-header {
  background-color: #282c34;
  min-height: 10vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  color: white;
}

min-height: 10vh としたので、小さくなりました。

f:id:swiftfe:20190501073412p:plain

画面が大きいと不恰好ですが、疲れたのでここまでにします。後はcss職人のみなさんの頑張りに期待します。cssむずいです。

ちなみに、CSSは以下の資料がわかりやすいので一度見てみるといいと思います。

speakerdeck.com

saruwakakun.com

Next Step Hint 1: 複数ページのサイトを作る

今回作ったサイトは一ページだけでした。複数のページにまたがっているパターンが普通だと思います。

react-routerというライブラリを使えば、SPAでも複数のページを表現できます。

qiita.com

Next Step Hint 2: デザインをGoogleっぽくする

Google系のサイトで使われているMaterial Designというデザインルールに準拠したコンポーネントを使って見た目をいい感じにすることもできます。Youtubeなどを見るとイメージがわきやすいかと思います。

The world's most popular React UI framework - Material-UI というライブラリを使えば、簡単にMaterial Designなサイトをつくれます。

例えばボタンは以下のリンクに作り方がのってます。

material-ui.com

導入方法は以下を参考にすると良いかと思います。

rennnosukesann.hatenablog.com

公式サイトに色々なパーツの使い方が載っているので、色々試してみると面白いと思います。

f:id:swiftfe:20190501064044p:plain

Next Step Hint 3: Firestoreを使ってDBの処理に慣れる

以下の記事がわかりやすそうでした。

React.js Firebase Tutorial: Building Firestore CRUD Web Application

シリーズ