1. 記事一覧 >
  2. ブログ記事
category logo

Node.js,Python,ReactでDaprの状態管理アプリを作成してローカル環境で動作確認

(更新) (公開)

はじめに

Dapr のサービス呼び出しと状態管理を利用する簡易 Web アプリを作成しました。何もない状態からスタートで記事にしたいと思います。
今回は、Dapr の Service-to-service invocation(サービス呼び出し) と State management(状態管理) を使います。
Dapr CLI を使って、動作確認するところまで実施します。
なお、続きの記事も同時に公開していて、そちらは、この記事のアプリを Bicep を使って、Azure Container Apps + Dapr 環境 にデプロイします。
次回記事:「Bicepを使ってAzure Container AppsとDaprのマイクロサービスをデプロイ

Dapr 概要図

注意:今回も、次回も、記事中に Kubernetes 絡みの話は出てきません。

【 Dapr(ダパァ/ダッパー) 】

Dapr は、クラウドネイティブおよびサーバーレスコンピューティングをサポートするように設計された無料のオープンソースランタイムシステムです。

Dapr は、いろいろなビルディングブロック(機能)を有し、HTTP API/gRPC API で提供します。(一つ一つの説明は省略します。)

API は、様々な言語で呼び出すことができて、クラウドインフラ、エッジインフラ、オンプレミス環境で利用可能です。

Dapr は、コンテナーの個別のプロセスとして、サイドカー アーキテクチャでアプリケーションと共に実行されます。

どの環境でも(今回利用する State management の場合、どの DB を使っても。)アプリは、同一の実装になります。必要なのは、Dapr コンポーネントの差し替えになります。

Dapr(ダパァ/ダッパー)

【 ビルディングブロック 】

ビルディング ブロックは、コードから呼び出すことができ、1 つ以上の Dapr コンポーネントを使用する HTTP または gRPC API です。

Dapr のコンポーネントは、Dapr の各ビルディング ブロック機能の具体的な実装を提供します。

ビルディングブロック

【 サイドカーパターン 】

サイドカーパターンは、メインのコンテナと、補助的な機能を提供するコンテナで構成されます。

このパターンは、オートバイに取り付けられるサイドカーに似ているため、"サイドカー" と名付けられています。

このパターンでは、サイドカーは親アプリケーションに接続され、アプリケーションにサポート機能を提供します。

サイドカーパターン

【 Service-to-service invocation サービス呼び出し 】

サービス呼び出しを使用すると、アプリケーションは、標準の gRPC または HTTP プロトコルを使用して、他のアプリケーションと確実かつ安全に通信できます。

サービスディスカバリ(IP アドレス:ポートを見つけたり、ドメイン名を DNS に登録したり)の心配は有りません。サービス名で連携先サービスを呼び出せます。

動作概要:

Service-to-service invocation サービス呼び出し

1. サービス A は、サービス B をターゲットとする HTTP または gRPC 呼び出しを行います。呼び出しは、ローカルの Dapr サイドカーに送られます。

2. Dapr は、指定されたホスティング プラットフォームで実行されている名前解決コンポーネントを使用して、サービス B の場所を検出します。

3. Dapr がメッセージをサービス B の Dapr サイドカーに転送します。

 注: Dapr サイドカー間のすべての呼び出しは、パフォーマンスのために gRPC を経由します。

 サービスと Dapr サイドカー間の呼び出しのみ、HTTP または gRPC のいずれかにすることができます。

4. サービス B の Dapr サイドカーは、サービス B の指定されたエンドポイント (またはメソッド) にリクエストを転送します。次に、サービス B はそのビジネス ロジック コードを実行します。

5. サービス B はサービス A に応答を送信します。応答はサービス B のサイドカーに送信されます。

6. Dapr は、サービス A の Dapr サイドカーに応答を転送します。

7. サービス A が応答を受信します。

【 State management 状態管理 】

Dapr は、キー/値ベースの状態およびクエリ API を提供します。

サポートされているストア(バックエンドの DB)は、たくさんあります。

こちら(https://docs.dapr.io/reference/components-reference/supported-state-stores/)にリストがありますが、今回、この中から、Redis を使います。

次回の記事では、Azure Cosmos DB を使います。

作業を始める前に、docker, python, node インストール済みとします。

【検証環境】

Ubuntu 20.04.2 LTS

 node v14.20.0

 Python 3.8.10

 Docker 20.10.21

 Dapr CLI 1.9.1

 Dapr Runtime 1.9.5

 React 18.2.0


概要

node-service - dapr → dapr - python-service
と python に実装されている /order API を呼び出して、データ登録、取得、削除を行います。
データはハードコーディングされていて、1種類だけとします。

Dapr利用の今回作成するアプリ概要図

こちら(https://github.com/Azure-Samples/container-apps-store-api-microservice)のソースコードを参考にしていて、大部分同じ記述があります。


画面は、React です。ビルドして使います。node-service で / など、API 以外のアクセスがあった時、ビルド済みの index.html を読み取って返しています。
React である必然性はかなり少なく、index.html とか手で書いて置いても良かったかなとは思います。


ソースコード準備

作成済みの全体ソースコードは、GitHub リポジトリ https://github.com/itc-lab/azure-dapr-bicep-simple-app にアップしました。(注意:リポジトリは、次回の記事の内容も含みます。)

create-react-app で画面作成(node-service/client/

ホスティング/ルーティングのための node プログラム作成(node-service/index.js

order API サービスの python プログラム作成(python-service/
と準備していきます。


create-react-app

$ mkdir -p hello-dapr-app/node-service
$ cd hello-dapr-app/node-service
$ npx create-react-app client --template typescript

client ディレクトリ配下に:3000 で起動する React アプリ一式が作成されます。 これの node-service/client/src/App.tsx を以下の内容に書き換えます。

node-service/client/src/App.tsx
import React, { useState } from "react";
import "./App.css";

function App() {
  const my_api_url =
    process.env.REACT_APP_MY_API_URL || "http://localhost:3000";
  const [message, setMessage] = useState("");
  const postOrder = async () => {
    try {
      const res = await fetch(`${my_api_url}/order`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id: "123" }),
      });
      if (res.status === 200) {
        const response = await res.text();
        setMessage(response);
      } else {
        setMessage("Some error occured");
      }
    } catch (err) {
      console.log(err);
    }
  };
  const getOrder = async () => {
    try {
      const res = await fetch(`${my_api_url}/order?id=123`, {
        method: "GET",
      });
      if (res.status === 200) {
        const response = await res.text();
        setMessage(response);
      } else {
        setMessage("Some error occured");
      }
    } catch (err) {
      console.log(err);
    }
  };
  const deleteOrder = async () => {
    try {
      const res = await fetch(`${my_api_url}/delete`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id: "123" }),
      });
      if (res.status === 200) {
        const response = await res.text();
        setMessage(response);
      } else {
        setMessage("Some error occured");
      }
    } catch (err) {
      console.log(err);
    }
  };
  return (
    <div>
      <button onClick={postOrder}>POST</button>
      <button onClick={getOrder}>GET</button>
      <button onClick={deleteOrder}>DELETE</button>
      <div dangerouslySetInnerHTML={{ __html: message }}></div>
    </div>
  );
}

export default App;

$ cd client
$ npm start

結果、http://localhost:3000 が以下のような画面になります。

localhost 3000 の画面


最終的にビルドして、node.js → client/* をビルドした結果の html,js を参照させますので、CTRL + C で停止します。


node.js

node.js + express + axios により、HTTP サーバー + Dapr へリクエストする役割のサービスを作成します。

$ cd node-service
$ npm init
(全部エンター)
$ npm install axios
$ npm install express

node-service/package.jsonscripts に 画面ビルド buildclient、サーバー起動 start を追加しておきます。

node-service/package.json
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "buildclient": "cd client && npm install && npm run build",
    "start": "node index.js"
  },

node-service/index.js を以下のように作成します。

node-service/index.js
const express = require("express");
const path = require("path");
const axios = require("axios");

const app = express();
app.use(express.json());

const port = 3000;
const pythonService = process.env.PYTHON_SERVICE_NAME || "python-app";
const daprPort = process.env.DAPR_HTTP_PORT || 3500;

//use dapr http proxy (header) to call orders service with normal /order route URL in axios.get call
const daprSidecar = `http://localhost:${daprPort}`;

app.get("/order", async (req, res) => {
  try {
    var data = await axios.get(`${daprSidecar}/order?id=${req.query.id}`, {
      headers: { "dapr-app-id": `${pythonService}` }, //sets app name for service discovery
    });
    res.setHeader("Content-Type", "application/json");
    res.send(
      `<p>Order GET successfully!</p><br/><code>${JSON.stringify(
        data.data
      )}</code>`
    );
  } catch (err) {
    res.send(
      `<p>Error getting order<br/>Order microservice or dapr may not be running.<br/></p><br/><code>${err}</code>`
    );
  }
});

app.post("/order", async (req, res) => {
  try {
    var order = req.body;
    order["location"] = "Seattle";
    order["priority"] = "Standard";
    console.log(
      "Service invoke POST to: " +
        `${daprSidecar}/order?id=${req.query.id}` +
        ", with data: " +
        JSON.stringify(order)
    );
    var data = await axios.post(
      `${daprSidecar}/order?id=${req.query.id}`,
      order,
      {
        headers: { "dapr-app-id": `${pythonService}` }, //sets app name for service discovery
      }
    );

    res.send(
      `<p>Order created!</p><br/><code>${JSON.stringify(data.data)}</code>`
    );
  } catch (err) {
    res.send(
      `<p>Error creating order<br/>Order microservice or dapr may not be running.<br/></p><br/><code>${err}</code>`
    );
  }
});

app.post("/delete", async (req, res) => {
  try {
    var data = await axios.delete(`${daprSidecar}/order?id=${req.body.id}`, {
      headers: { "dapr-app-id": `${pythonService}` },
    });

    res.setHeader("Content-Type", "application/json");
    res.send(`${JSON.stringify(data.data)}`);
  } catch (err) {
    res.send(
      `<p>Error deleting order<br/>Order microservice or dapr may not be running.<br/></p><br/><code>${err}</code>`
    );
  }
});

// Serve static files
app.use(express.static(path.join(__dirname, "client/build")));

// For default home request route to React client
app.get("/", async function (_req, res) {
  try {
    return await res.sendFile(
      path.join(__dirname, "client/build", "index.html")
    );
  } catch (err) {
    console.log(err);
  }
});

app.listen(process.env.PORT || port, () =>
  console.log(`Listening on port ${port}!`)
);

起動するかどうか確認します。

$ npm start

Listening on port 3000! と表示されたら、正常ですので、CTRL + C で止めます。


Dapr に関係するキモとなる実装は、

var data = await axios.get(`${daprSidecar}/order?id=${req.query.id}`, {

headers: { "dapr-app-id": `${pythonService}` }, //sets app name for service discovery

});

のように、API(今回は、python の :5000)ではなく、Dapr にリクエストしているところです。

dapr-app-id: <Daprに伝えてあるアプリ名> ヘッダにより、リクエスト先が python だと分かります。

したがって、python の IP アドレスやホスト名を知る必要はありません。


python

order API サービス側の実装を python で行います。

$ cd ../
(hello-dapr-app 直下)
$ mkdir python-service

python-service/app.py と 依存関係が書かれた python-service/requirements.txt を作成します。

python-service/app.py
import os
import logging
import flask
from flask import request, jsonify
from flask import json, abort
from flask_cors import CORS
from dapr.clients import DaprClient

logging.basicConfig(level=logging.INFO)

app = flask.Flask(__name__)
CORS(app)


@app.route("/order", methods=["GET"])
def getOrder():
    app.logger.info("order service called")
    with DaprClient() as d:
        d.wait(5)
        try:
            id = request.args.get("id")
            if id:
                # Get the order status from DB via Dapr
                state = d.get_state(store_name="orders", key=id)
                if state.data:
                    resp = jsonify(json.loads(state.data))
                else:
                    resp = jsonify("no order with that id found")
                resp.status_code = 200
                return resp
            else:
                resp = jsonify('Order "id" not found in query string')
                resp.status_code = 500
                return resp
        except Exception as e:
            app.logger.info(e)
            return str(e)
        finally:
            app.logger.info("completed order call")


@app.route("/order", methods=["POST"])
def createOrder():
    app.logger.info("create order called")
    with DaprClient() as d:
        d.wait(5)
        try:
            # Get ID from the request body
            id = request.json["id"]
            if id:
                # Save the order to DB via Dapr
                d.save_state(
                    store_name="orders", key=id, value=json.dumps(request.json)
                )
                resp = jsonify(request.json)
                resp.status_code = 200
                return resp
            else:
                resp = jsonify('Order "id" not found in query string')
                resp.status_code = 500
                return resp
        except Exception as e:
            app.logger.info(e)
            return str(e)
        finally:
            app.logger.info("created order")


@app.route("/order", methods=["DELETE"])
def deleteOrder():
    app.logger.info("delete called in the order service")
    with DaprClient() as d:
        d.wait(5)
        id = request.args.get("id")
        if id:
            # Delete the order status from DB via Dapr
            try:
                d.delete_state(store_name="orders", key=id)
                return f"Item {id} successfully deleted", 200
            except Exception as e:
                app.logger.info(e)
                return abort(500)
            finally:
                app.logger.info("completed order delete")
        else:
            resp = jsonify('Order "id" not found in query string')
            resp.status_code = 400
            return resp


app.run(host="0.0.0.0", port=os.getenv("PORT", "5000"))
python-service/requirements.txt
flask
flask_cors
dapr
dapr-ext-grpc
dapr-ext-fastapi

依存関係をインストールします。

$ cd python-service
$ pip3 install -r requirements.txt

起動するかどうか確認します。

$ python3 app.py

* Running on http://127.0.0.1:5000 と表示されたら、正常ですので、CTRL + C で止めます。


Dapr に関係するキモとなる実装は、

from dapr.clients import DaprClient

の部分と、

d.get_state(store_name="orders", key=id)

d.save_state(store_name="orders", key=id, value=json.dumps(request.json))

d.delete_state(store_name="orders", key=id)

の部分です。

状態管理コンポーネント(バックエンドDB)が何であってもこの実装のままでOKです。


Dapr インストール

今回、完全にオンプレミスな環境で動作確認するため、Dapr CLI をインストールします。Kubernetes クラスター作成とかは行いません。

$ wget -q https://raw.githubusercontent.com/dapr/cli/master/install/install.sh -O - | /bin/bash
$ dapr --version
CLI version: 1.9.1
Runtime version: n/a

Dapr を初期化します。
これにより、Dapr サイドカーバイナリと ローカル開発環境の Docker のコンテナが作成されます。Docker のコンテナには、Redis container instance、Zipkin container instance、default components folder、Dapr placement service container instance、が作成されます。
今回関係するのは、Redis container instance と default components folder で、Redis state store が状態管理のバックエンド DB として使えます。
状態管理 DB の設定は、デフォルトでは、~/.dapr/components/statestore.yaml にあり、statestore という名前の localhost:6379 の redis が使われるように設定されています。

Docker 無しでも初期化できますが、今回そちらは、説明しません。

$ dapr init
$ dapr --version
CLI version: 1.9.1
Runtime version: 1.9.5
$ docker ps
CONTAINER ID   IMAGE               COMMAND                  CREATED         STATUS                   PORTS                                                 NAMES
ee3efe641a43   redis:6             "docker-entrypoint.s…"   2 minutes ago   Up 2 minutes             0.0.0.0:6379->6379/tcp, :::6379->6379/tcp             dapr_redis
ffbeaf1d4bcc   daprio/dapr:1.9.5   "./placement"            2 minutes ago   Up 2 minutes             0.0.0.0:50005->50005/tcp, :::50005->50005/tcp         dapr_placement
e2f9f31afec8   openzipkin/zipkin   "start-zipkin"           2 minutes ago   Up 2 minutes (healthy)   9410/tcp, 0.0.0.0:9411->9411/tcp, :::9411->9411/tcp   dapr_zipkin

Dapr Run

基本的に、
dapr run --app-id <アプリ名> --app-port <アプリのポート> --dapr-http-port <daprのポート> <起動コマンド>
で起動するのですが、その前にやっておかないといけないことが2点あります。


・画面(React)のビルド

画面は、node.js の HTTP サーバーから client/build/index.html を参照するだけにしたいため、ビルドします。

$ cd node-service
$ npm run buildclient

・statestore.yaml

今回、ストア名(DB名)を statestore ではなく、 orders で実装しているため、statestore.yaml を別に作成しておきます。

$ mkdir -p dapr-components/local
$ vi dapr-components/local/statestore.yaml
dapr-components/local/statestore.yaml
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: orders
spec:
  type: state.redis
  version: v1
  metadata:
    - name: redisHost
      value: localhost:6379
    - name: redisPassword
      value: ""
scopes:
  - python-app

ここまでで、以下の状況です。(node-service/node_modulesnode-service/client/node_modulesnode-service/client/build は除外しています。)

~/hello-dapr-app
|-- dapr-components
|   `-- local
|       `-- statestore.yaml
|-- node-service
|   |-- client
|   |   |-- package.json
|   |   |-- package-lock.json
|   |   |-- public
|   |   |   |-- favicon.ico
|   |   |   |-- index.html
|   |   |   |-- logo192.png
|   |   |   |-- logo512.png
|   |   |   |-- manifest.json
|   |   |   `-- robots.txt
|   |   |-- README.md
|   |   |-- src
|   |   |   |-- App.css
|   |   |   |-- App.test.tsx
|   |   |   |-- App.tsx
|   |   |   |-- index.css
|   |   |   |-- index.tsx
|   |   |   |-- logo.svg
|   |   |   |-- react-app-env.d.ts
|   |   |   |-- reportWebVitals.ts
|   |   |   `-- setupTests.ts
|   |   `-- tsconfig.json
|   |-- index.js
|   |-- package.json
|   `-- package-lock.json
`-- python-service
    |-- app.py
    `-- requirements.txt

準備が終わったら、いよいよ起動です。


・python 起動

dapr-components/local/statestore.yaml を指定して起動します。

$ cd python-service
$ dapr run --app-id python-app --app-port 5000 --dapr-http-port 3500 --components-path ../dapr-components/local python3 app.py

・node 起動

node の方は、状態管理が無いため、--components-path 無しで起動します。

$ cd node-service
$ dapr run --app-id node-app --app-port 3000 --dapr-http-port 3501 npm start

動作確認

1. データ無しの状態で、GET してみます。

データ無しの状態で、GET

データが無いという結果が返りました。


2. POST してデータを入れます。

POST してデータ入力

実装を最小限にするため、データは固定です。


3. 再びデータを GET します。

再びデータを GET

データが取り出されました。


4. データを DELETE します。

データを DELETE 削除に成功しました。


5. 再びデータを GET します。 削除後、再びデータを GET データ無しに戻ります。


ちなみに、DB が存在しなかった場合、以下のエラーになります。今回の場合、--components-path 無しで python を起動するとこうなります。
<_InactiveRpcError of RPC that terminated with:\n\tstatus = StatusCode.INVALID_ARGUMENT\n\tdetails = \"state store orders is not found...

DB が存在しなかった場合 エラー


通信先の Dapr サイドカーが無いとき、以下のエラーになります。(あくまで今回の実装の場合です。)
AxiosError: Request failed with status code 500

Dapr サイドカーが無いとき エラー


ヨシ!


続きの記事へ →「Bicepを使ってAzure Container AppsとDaprのマイクロサービスをデプロイ

loading...