GERNスタックを使ってCRUDを操作する

このブログでは、GERNスタックの使い方と、React、Express、GridDBでCRUD API (Create, Read, Update, Delete) を適切に実装する方法を紹介します。前回のブログでGERNスタックを紹介し、そのプロジェクトをベアメタルで実行した内容の続編です。詳しくはこちらをご覧ください。

これらの方法を紹介するために、データテーブルに偽のIoT的なセンサーデータをロードする、ある種のIoT的な環境を構築します。生成されるデータは、センサーの温度と湿度の両方です。また、このシンプルなアプリケーションのDocker環境を作成し、ユーザーが手間をかけずに自分のマシンで試せるようにしています。

現実のシナリオを想定して、データと温度の値は更新できないようにしました。Locationカラムは更新できるようにしています(センサーが新しいビルに移動することを想定しています)。全てのセンサーはページロード時に生成され、GridDBの sensorsblog というコンテナに保存されます。また、ユーザが新しいセンサーをテーブルに追加することも可能で、その場合はランダムな値(時刻を除く)が生成されます。

Update は、センサーの位置を更新します。Deleting は行をそのまま削除し、コンテナに再クエリを行い、GridDB サーバからのライブデータでテーブルを更新します。

さらに、いくつかのDockerコンテナをセットアップしたことにより、単純な docker-compose コマンドを使ってこのプロジェクトの全体を実行することができます。これらのイメージはすべてDockerhubで利用可能です。

前提条件

このプロジェクトを実行するには、次の前提条件が必要となります。

  • docker-engine
  • docker-compose

.toc_container {
    border: 1px solid #aaa !important;
    display: table !important;
    font-size: 95%;
    margin-bottom: 1em;
    padding: 20px;
    width: auto;
}

.toc_title {
    font-weight: 700;
    text-align: center;
}

.toc_container li,
.toc_container ul,
.toc_container ul li,
.toc_container ol li {
    list-style: outside none none !important;
}

.single-post .post>.entry-content { font-size: 14px !important }

Dockerでプロジェクトを実行する

このプロジェクトを実行するために、まずはマシンにdockerとdocker-composeをインストールしてください。

docker-composeが既にインストールされているかどうかを確認します。

$ docker-compose --version

インストールされていない場合は、こちらからインストールできます。: https://docs.docker.com/compose/install/

Docker Compose

まず始めに、ソースコードを全て取得してみましょう。

$ git clone --branch react_crud https://github.com/griddbnet/Blogs.git

このプロジェクトをDocker composeで実行するために、以下のコマンドを実行します。

$ docker-compose up

Dockerhubからのイメージの取得が完了すると、フロントエンドが実行され、GridDBデータベース自体が起動されます。この時点で、ブラウザに移動してプロジェクトを見ることができますが、実際にプロジェクトを見るには、Dockerの出力でGridDBが完全に実行されていることが示されるまで待つ必要があります。そうしないと、何の機能もない、ほとんど空のテーブルが表示されるだけになってしまうためです。

実行する様子をGIFで紹介します。

docker-compose.yml ファイルは以下のようになります。

version: '3'

services: 

  griddb-server: 
    image: griddbnet/griddb:5.0
    
    expose: 
      - "10001" 
      - "10010" 
      - "10020"
      - "10040"
      - "20001" 

    healthcheck:
        test: ["CMD", "tail", "-f", "tail -f /var/lib/gridstore/log/gridstore*.log"]
        interval: 30s
        timeout: 10s
        retries: 5


  frontend:
    image: griddbnet/griddb-react-crud
    command: bash -c "sleep 30"
    ports:
      - "2828:2828"

    restart: unless-stopped
    depends_on: 
      - griddb-server
    links: 
      - griddb-server

この docker-compose ファイルは、dockerhub イメージを経由して griddb データベースとフロントエンドのイメージを取得します。フロントエンドのサービスは griddb-server に depend_on するように設定しました。DB がなければ Web ページは何もできないためです。

docker-composeの特長は、yamlファイルや環境を共有しているので、この2つのコンテナは自動的に同じネットワークに配置され、2つのコンテナ間の通信が非常にシンプルになるという点です。

GridDB-Serverコンテナは、前回のブログで紹介したオリジナルのdockerコンテナで構築されている他は、基本的に何も特別なことはしていません。公開されている様々なポートは、GridDBが動作するために必要なポートです。詳しくはこちらのQuickStartをご覧ください。

ここでは、各ポートの役割について簡単に説明します。

  "cluster": {"address":"172.17.0.45", "port":10010},
  "sync": {"address":"172.17.0.45", "port":10020},
  "system": {"address":"172.17.0.45", "port":10040},
  "transaction": {"address":"172.17.0.45", "port":10001},
  "sql": {"address":"172.17.0.45", "port":20001}

これらのコンテナがビルドされる様子を確認したい場合は、Dockerfileの各コンテンツに目を通すことができます。

このプロジェクトを docker-compose 経由で実行した場合は、CRUDセクションにスキップすることができます。

他の方法として、docker-compose ではなく、通常の docker コンテナを通してプロジェクトを実行するところを確認することもできます。

Dockerで実行する(docker-composeを除く)

GridDBサーバコンテナ

To run GridDB server, you can simply pull from Dockerhub GridDBサーバを動かすには、単純にDockerhubからPullします。

$ docker network create griddb-net
$ docker pull griddbnet/griddb:5.0

まず、アプリケーションコンテナとGridDBコンテナが簡単に通信できるように、Dockerネットワークを作成します。

次に、GridDBサーバを起動します。

$  docker run --network griddb-net --name griddb-server -d -t griddbnet/griddb

Nodejs アプリケーションコンテナ

アプリケーションのNodejs部分を実行するために、このディレクトリのルートにDockerfileがあります。

イメージを構築し、簡単に実行します。

$ docker pull griddbnet/griddb-react-crud
$ docker run --network griddb-net --name griddbnet/griddb-react-crud -p 2828:2828 -d -t griddbnet/griddb-react-crud

そして、http://localhost:2828に移動すると、完全なアプリが実行されているのが確認できます。

このコンテナの仕組みが気になる方は、こちらの [過去のブログ] (https://griddb.net/en/blog/improve-your-devops-with-griddb-server-and-client-docker-containers/)をご覧ください。

プロジェクトの概要

このブログでは、GERN Stackをコンテナ形式で動かしています。つまり、データベース層にGridDB、バックエンド、サーバ層にnode.jsとExpress.js、フロントエンド層にReact.jsを搭載しています。

GridDB — データベース

GridDBはこのプロジェクトのための永続的なストレージデータベースで、GERNスタックのGです。データは sensorsblog コンテナに保存され、ページロード時に自動生成された偽のセンサデータが格納されます。このコンテナは time series コンテナであり、行のキーは時間です。

データベースは、バックエンド(node.js)からCRUDリクエストを受信・送信するために、node.jsクライアントAPIコールと同時に使用されるSQLクエリ文字列を受け取ります。

node.js & Express.js — バックエンド

これは、私たちのウェブページがホストされている場所です。CRUD 機能のルートを提供し、React.js のフロントエンドをホストします。Express.jsによって作成されたエンドポイントは、データベースとフロントエンドとのやりとりに対応します。

例えば /delete は、フロントエンドからバックエンドに rowKey を送信し、さらに SQL 文字列を介して GridDB に送信し、どの行が削除されるかをデータベースに知らせます。CRUD操作には、それぞれエンドポイントが必要です。

React.js — フロントエンド

React.jsは、ページを再読み込みすることなく、新しいデータを取り込むことができるリアクティブなウェブページを実現するためのフロントエンドです。今回のプロジェクトでは、すべての情報を含むホーム画面から離れて操作しなくても、バックエンドにデータを送り返すことができるため、非常に役に立ちました。

CRUD オペレーション

先に説明したように、CRUDはCREATE、READ、UPDATE、DELETEの頭文字をとったものです。これらは、ユーザーがログオフしたり、アプリを消したりした後でも持続するような、永続的ストレージアプリで使用される主な操作です。

CREATE

CREATEは、まさにその名の通り、コンテナとフロントエンドデータテーブルに新しいデータを追加するための操作です。

ここでは、CREATEを紹介するために偽のセンサーデータを生成し、その記録を時系列コンテナに保存して、ページロード時にそのデータを即座に読み込みます。ページがロードされるたびに、コンテナは削除され、再作成されます。この場合、エンドポイント /firstLoad は最初のページロード時に呼び出されます。

以下のコードスニペットは、フロントエンドのものです。React Use Effect フックの末尾に空の配列があるのは、ページロード時に一度だけ実行されることを意味しています。ここでは /firstLoad エンドポイントを呼び出し、GridDB サーバから送信されたデータをデータテーブルに適合するようウォッシュしているのがわかります。

  const queryForRows = (endPoint) => {
    var xhr = new XMLHttpRequest();
    console.log("endpoint: ", endPoint)

    xhr.onreadystatechange = function () {

      if (xhr.readyState !== 4) return;
      if (xhr.status >= 200 && xhr.status < 300) {
        let resp = JSON.parse(xhr.responseText);
        let res = resp.results


        var t = []
        for (let i = 0; i < res.length; i++) {
          let obj = {}
          obj["id"] = i
          obj["timestamp"] = res[i][0]
          obj["location"] = res[i][1]
          obj["data"] = res[i][2].toFixed(2)
          obj["temperature"] = res[i][3]
          t.push(obj)
        }
        //console.log("rows: ", rows)
        setRows([...t])
      }
    };
    xhr.open('GET', endPoint);
    xhr.send();
  }

  useEffect(() => { //Runs on every page load
    queryForRows("/firstLoad");
  }, [])

そして、これがバックエンドのコードです。

app.get('/firstLoad', async (req, res) => {
    try {
        await putCont(true, 10);
        let queryStr = "select *"
        var results = await queryCont(queryStr)
        res.json({
            results
        });
    } catch (error) {
        console.log("try error: ", error)
    }
});

このエンドポイントが呼ばれると、関数 putCont(firstload, sensorCount); が呼び出されます。この関数が CREATE を処理します。

const putCont = async (firstLoad, sensorCount) => {
    const rows = generateSensors(sensorCount);
    try {
        if (firstLoad) {
            await store.dropContainer(containerName);
        }
        const cont = await store.putContainer(conInfo)
        await cont.multiPut(rows);
    } catch (error) {
        console.log("error: ", error)
    }
}

const generateSensors = (sensorCount) => {

    let numSensors = sensorCount
    let arr = []

    for (let i = 1; i <= numSensors; i++) {
        let tmp = [];
        let now = new Date();
        let newTime = now.setMilliseconds(now.getMinutes() + i)
        let data = parseFloat(getRandomFloat(1, 10).toFixed(2))
        let temperature = parseFloat(getRandomFloat(60, 130).toFixed(2))
        tmp.push(newTime)
        tmp.push("A1")
        tmp.push(data)
        tmp.push(temperature)
        arr.push(tmp)
    }
 //   console.log("arr: ", arr)
    return arr;
}

putCont が呼ばれると、まず generateSensors を呼び出して、現在の時刻に基づいた偽のセンサーデータを作成します。データの作成が終わると、(存在する場合と firstLoad の場合は)古いコンテナを削除し、次に putContainermultiPut で生成したセンサーデータの全ての行を削除します。

また、/createRowエンドポイントも用意されており、一度に1つの行・センサーを作成することができます。

  // Frontend/React code
  const createRow = useCallback(() => {
    fetch('/create', {
      method: 'GET',
    }).then(function (response) {
      console.log(response)
      return response.json();
    });
  }, [])

  const handleCreateRow = async () => {
    await createRow();
    queryForRows("/updateRows");
  }

そしてバックエンドは以下のようになります。

app.get("/create", async (req, res) => {
    console.log("Creating")
    try {
        await putCont(false, 1);
        console.log("creating row")
        res.status(200).json(true)
    } catch (err) {
        console.log("/create error: ", err)
    }
    
})

ここでは再び putCont コンテナを使用しますが、今回はコンテナを落とさないように false フラグを送信し、さらに 1 の int を送信して 1 行 、センサーのみを作成するようにします。これは、行を作成するのがいかに簡単かを示しています。

もう一つの注意点は、handleCreateRow関数が呼ばれるたびに(ユーザが適切なUI要素をクリックするたびに)、JavaScriptはcreateRow関数が解決するまで「待機」して、CRUD APIの READ 部分である queryForRows 関数を再度実行することです。

READ

GridDBから読み込むには、単純にSQL文字列とクエリAPIコールを使用します。今回は、コンテナ名に対して select * を実行し、その結果を json としてフロントエンドに送信しています。

フロントエンドがデータを取得して変換すると、Reactフックを使って状態を更新し、更新されたデータでデータテーブルを再レンダリングします。

今回は、material ui for Reactを使って、アプリをきれいに仕上げています。

UPDATE

このアプリをより現実に近い設定にするために、LOCATIONカラムだけが更新可能になっています。GridDB で行を更新するには、単にコンテナに行を put します。もし、その行が既に存在する場合は、プッシュされた新しい値で更新されます。

フロントエンドの場合、マテリアルuiのデータテーブルコンポーネントは、ビルトイン機能を備えているため、更新が少し楽になります。フレームワークを使用することで、どの列が編集可能であるかを、最初からアプリに伝えることができます。この場合、編集可能なセルには緑色の背景を追加しました。

フロントエンドでは、ロケーションの編集を次のように処理しました。

  const updateRow = useCallback((row) => {
    fetch('/update', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ row })
    }).then(function (response) {
      console.log(response)
      return response.json();
    });
  }, [])

  const processRowUpdate = useCallback(
    async (newRow) => {
      const response = await updateRow(newRow);
      setSnackbar({ children: `New location of  ${newRow.location} successfully saved to row: ${newRow.id}`, severity: 'success' });
      return newRow;
    },
    [],
  );

今回のケースでは、行の更新処理を行うために useCallback React フックを使用しています。ユーザーが ENTER を押すか、編集可能なセルをクリックして離れると(基本的には 「編集停止」 イベント)、 processRowUpdate 関数が実行されて、 updateRow 関数が呼び出されます。これにより、新しい行が /update エンドポイントを持つバックエンドに送信されます。このエンドポイントでは、バックエンドに送信される行が 1 つだけであることを想定しています。

もし updateRow が正常に解決されると、スナックバーがポップアップし、行の更新が成功したことをユーザーに通知します。

app.post("/update", jsonParser, async (req, res) => {
    const newRowObj = req.body.row
    const newRowArr = []

    for (const [key, value] of Object.entries(newRowObj)) {
        newRowArr.push(value)
    }
    newRowArr.shift(); 
    newRowArr[2] = parseFloat(newRowArr[2])
    console.log("new row: from endpoint: ", newRowArr)

    try {
        let x = await updateRow(newRowArr)
        console.log("return of update row: ", x)
        res.status(200).json(true);
    } catch (err) {
        console.log("update endpoitn failure: ", err)
    }
});

このエンドポイントでは、フロントエンドからデータを取得し、GridDBの期待通りに、データをobjから配列に変換しています。そして、このデータを updateRow という関数に送ります。

const updateRow = async (newRow) => {
    try {
        const cont = await store.putContainer(conInfo)
        const res = await cont.put(newRow)
        return res
    } catch (err) {
        console.log("update row error: ", err)
    }
}

この関数は、単純に新しいデータを受け取り、その行を GridDB に戻します。行が存在する場合でも、その行を更新します。

行が実際に更新されたことを確認するには、 griddb シェルでコンテナ sensorsblog に常に問い合わせることができます。また、コンテナを削除してみると、コンテナの再読み込みとデータテーブル全体のリフレッシュが行われます。

DELETE

削除するには、GridDB の remove API キーを呼び出す。このとき、削除したい行のキーを API コールに渡します。IoT環境を模倣しているので、GridDBコンテナは時系列コンテナになっており、行キーはタイムスタンプになっています。

フロントエンドでは、ユーザーがチェックボックスで行を選択できるようにし、ユーザーがDeleteボタンをクリックすると、バックエンドに行を送り返し、処理をして行キー(タイムスタンプ)を抽出し、1つずつ削除するようにしています。

まず、バックエンドを見てみましょう。ここでは /delete エンドポイントを使用します。

app.post("/delete", jsonParser, async (req, res) => {
    const rows = req.body.rows

    try {
        let x = await deleteRow(rows)
        console.log("deleting rows")
        res.status(200).json(true)
    } catch (err) {
        console.log("delete row endpoint failure: ", err)
    }
})

ここでは特に変わったところはありません。しかし、ご覧の通り、deleteRow関数を呼び出しています。

// Backend code
const deleteRow = async (rows) => {
    console.log("Rows to be deleted: ", rows)
    var rowKeys = []
    rows.forEach ( row => {
        rowKeys.push(row.timestamp)
    })
    console.log("row keys: ", rowKeys) 
    try {
        const cont = await store.putContainer(conInfo)
        rowKeys.forEach ( async rowKey => {
            let res = await cont.remove(rowKey)
            console.log("Row deleted: ", res, rowKey)
        })
        return true
    } catch (err) {
        console.log("update row error: ", err)
    }

}

この関数は、フロントエンドの行データ全体から行番号を取得し、ループして各行番号をひとつずつ削除しています。

最後に重要なのは、フロントエンドから実際にdelete関数を実行した直後に、GridDBコンテナに再度問い合わせを行い、GridDBコンテナ内の実際のコンテンツでデータテーブルを更新することです。

削除ボタンがクリックされるたびに、以下の関数が実行されます。

// Frontend code
  const deleteRow = useCallback((rows) => {
    console.log("Str: ", rows)
    fetch('/delete', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ rows })
    }).then(function (response) {
      console.log(response)
      return response.json();
    });
  }, [])

  const handleDeleteRow = async () => {
    await deleteRow(selectedRows);
    queryForRows("/updateRows");
  }

ここで再び queryForRows を呼び出し、ユーザがコンテナの内容の更新されたビューを取得できるようにしています。行が削除されると、コンテナを再読み込みしてデータを変換し、GridDB コンテナの実際の内容でデータテーブルを更新します。

まとめ

プロジェクト全体が動いている様子をgifで紹介します。

ソースコード

ソースコードの全文はこちらでご覧になれます。

ブログの内容について疑問や質問がある場合は Q&A サイトである Stack Overflow に質問を投稿しましょう。 GridDB 開発者やエンジニアから速やかな回答が得られるようにするためにも "griddb" タグをつけることをお忘れなく。 https://stackoverflow.com/questions/ask?tags=griddb

Leave a Reply

Your email address will not be published. Required fields are marked *