- 記事一覧 >
- ブログ記事
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のマイクロサービスをデプロイ」
注意:今回も、次回も、記事中に Kubernetes 絡みの話は出てきません。
【 Dapr(ダパァ/ダッパー) 】
Dapr は、クラウドネイティブおよびサーバーレスコンピューティングをサポートするように設計された無料のオープンソースランタイムシステムです。
Dapr は、いろいろなビルディングブロック(機能)を有し、HTTP API/gRPC API で提供します。(一つ一つの説明は省略します。)
API は、様々な言語で呼び出すことができて、クラウドインフラ、エッジインフラ、オンプレミス環境で利用可能です。
Dapr は、コンテナーの個別のプロセスとして、サイドカー アーキテクチャでアプリケーションと共に実行されます。
どの環境でも(今回利用する State management の場合、どの DB を使っても。)アプリは、同一の実装になります。必要なのは、Dapr コンポーネントの差し替えになります。
【 ビルディングブロック 】
ビルディング ブロックは、コードから呼び出すことができ、1 つ以上の Dapr コンポーネントを使用する HTTP または gRPC API です。
Dapr のコンポーネントは、Dapr の各ビルディング ブロック機能の具体的な実装を提供します。
【 サイドカーパターン 】
サイドカーパターンは、メインのコンテナと、補助的な機能を提供するコンテナで構成されます。
このパターンは、オートバイに取り付けられるサイドカーに似ているため、"サイドカー" と名付けられています。
このパターンでは、サイドカーは親アプリケーションに接続され、アプリケーションにサポート機能を提供します。
【 Service-to-service invocation サービス呼び出し 】
サービス呼び出しを使用すると、アプリケーションは、標準の gRPC または HTTP プロトコルを使用して、他のアプリケーションと確実かつ安全に通信できます。
サービスディスカバリ(IP アドレス:ポートを見つけたり、ドメイン名を DNS に登録したり)の心配は有りません。サービス名で連携先サービスを呼び出せます。
動作概要:
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種類だけとします。
こちら(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
を以下の内容に書き換えます。
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
が以下のような画面になります。
最終的にビルドして、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.json
の scripts
に 画面ビルド buildclient
、サーバー起動 start
を追加しておきます。
"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
を以下のように作成します。
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
を作成します。
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"))
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
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_modules
、node-service/client/node_modules
、node-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 してみます。
データが無いという結果が返りました。
2. POST してデータを入れます。
実装を最小限にするため、データは固定です。
3. 再びデータを GET します。
データが取り出されました。
4. データを DELETE します。
5. 再びデータを GET します。 データ無しに戻ります。
ちなみに、DB が存在しなかった場合、以下のエラーになります。今回の場合、--components-path
無しで python を起動するとこうなります。<_InactiveRpcError of RPC that terminated with:\n\tstatus = StatusCode.INVALID_ARGUMENT\n\tdetails = \"state store orders is not found...
通信先の Dapr サイドカーが無いとき、以下のエラーになります。(あくまで今回の実装の場合です。)AxiosError: Request failed with status code 500
ヨシ!
その他、宣伝、誹謗中傷等、当方が不適切と判断した書き込みは、理由の如何を問わず、投稿者に断りなく削除します。
書き込み内容について、一切の責任を負いません。
このコメント機能は、予告無く廃止する可能性があります。ご了承ください。
コメントの削除をご依頼の場合はTwitterのDM等でご連絡ください。