谷本 心 in せろ部屋

はてなダイアリーから引っ越してきました

Dapr Advent Calendar 9日目 - DaprでOAuth2を使う

オーッス! Dapr Advent Calendar 9日目デーッス! 今日のテーマは OAuthッス! オーッス!

DaprでOAuth2による認可を行ってみよう

冒頭の元気な挨拶のことは忘れていただき、今回はDaprへのアクセスにOAuth 2.0による認可を行います。

f:id:cero-t:20211209070752p:plain
今回作るアプリケーション

DaprのOAuth対応についてのドキュメントはここにあります。

docs.dapr.io

DaprではOAuthサーバとしてTwitterやSlack、Azure AD、Google APIsなどを利用することができます。今回はGitHubをOAuthサーバとして利用します。

今回作成するアプリケーションのソースコードgithubに置いてあります。

https://github.com/cero-t/dapr-advent-2021/tree/main/oauth

僕はセキュリティにあまり詳しくないので、OAuthを認証代わりに使うのはおかしいとか、説明が間違ってるだとか色々あるかも知れませんので、何かあればTwitterなどでお知らせください。

OAuthを使うアプリケーションの作成

コントローラークラスの作成

まずはWebアプリケーションの作成です。別にアプリケーション自体は特に何でも構わないので、Hello World程度にしておきます。

OAuthController.java

@RestController
public class OAuthController {
    @GetMapping("/hello")
    public Map<String, String> hello() {
        return Map.of("message", "Hello, world!");
    }

    @GetMapping("/token")
    public Map<String, ?> token(@RequestHeader("x-oauth-token") String oauthToken) {
        return Map.of("x-oauth-token", oauthToken);
    }
}

Hello Worldとは別に、リクエストの x-oauth-token ヘッダをそのまま返すAPIも追加しました。このヘッダについてはDaprの設定ファイルのところで説明します。

ポート番号をアプリケーション設定ファイルで指定

続いて、設定ファイルでアプリケーションの起動ポートを指定します。

application.properties

server.port=8088

このアプリケーションは8088番ポートを利用します。

GitHubのOAuthを利用するためのDapr設定ファイルの作成

GitHubのOAuthを利用するためのDaprのコンポーネント設定ファイルを作成します。

components/oauth.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: oauth2
spec:
  type: middleware.http.oauth2
  metadata:
  - name: clientId
    value: "***"
  - name: clientSecret
    value: "***"
  - name: scopes
    value: ""
  - name: authURL
    value: "https://github.com/login/oauth/authorize"
  - name: tokenURL
    value: "https://github.com/login/oauth/access_token"
  - name: redirectURL
    value: "http://localhost:18088"
  - name: authHeaderName
    value: "x-oauth-token"

metadata.name は、ソースコードでは使いませんが、また別のDapr設定ファイルで使うので、分かりやすい名前にしておきます。

clientIdclientSecretGitHubで発行します。公式ドキュメントを参考にして、OAuth Appsを作成しました。

docs.github.com

OAuth Appを作成する際の「Homepage URL」の「Authorization callback URL」は、いずれも http://localhost にしました。コールバックURLには実際にはポート番号が入るのですが、GitHub側のOAuthではポート番号は省略しても大丈夫なようです。

OAuth Appを作成すると「Client ID」が発行されるので、これを clientId に指定します。また「Client secrets」にある「Generate a new client secret」ボタンを押すと「Client secret」が発行されるので、これを clientSecret に指定します。

scopes は空で構いません。

authURLtokenURLGitHubのドキュメントに書かれているものを使います。

docs.github.com

redirectURL は、GitHub側での認可が終わったときに戻ってくるアプリケーション側のURLです。認可後にはDaprに戻ってくる必要があるため、Daprのポート番号として利用予定の18088番ポートを指定したURLにしました。

authHeaderName は、GitHubで発行されたトークンをDaprからアプリケーション側に渡す際のHTTPヘッダ名です。x-oauth-token を指定しています。上で作ったコントローラークラスは、この値をそのまま返しているわけです。

public Map<String, ?> token(@RequestHeader("x-oauth-token") String oauthToken) {
    return Map.of("x-oauth-token", oauthToken);
}

少し長くなりましたが、これでDaprのコンポーネント設定ファイルができました。

OAuthを行うためのパイプライン設定ファイルの作成

次に、OAuthを有効にするためにDaprのパイプライン設定ファイルを作成します。このファイルは前回の分散トレーシングを設定する際にも利用したもので、Dapr内部でフィルタのように動作する「Middleware」の設定を行うためのファイルです。

次のような設定を行います。

config.yaml

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: appconfig
spec:
  httpPipeline:
    handlers:
    - name: oauth2
      type: middleware.http.oauth2

metadata.name はこの設定の名称です。任意の名前で構いません。

spec.httpPipeline はDaprを通過するHTTPリクエストに対して行う処理のパイプラインを記載するもので、handlers には複数のMiddlewareコンポーネントを列挙することができます。今回はOauth2の設定のみを記述しました。

handlers.namehandlers.type には、それぞれコンポーネント設定ファイルで記載した metadata.namespec.type の値を指定します。nameだけ列挙すれば良いような気もするのですが、typeもあわせて列挙する仕様となっています。

これでパイプライン設定ファイルの作成は完了です。

Daprを使ったアプリケーションの起動

それではDaprを使ってアプリケーションを起動します。

dapr run --app-id oauth-app --app-port 8088 --dapr-http-port 18088 --components-path ./components --config config.yaml ../mvnw spring-boot:run

Daprのコマンド引数に --config config.yaml を追加して、新規に作成したパイプライン設定ファイルが有効になるようにしています。

ブラウザからアクセスして確認

アプリケーションが起動したら、ブラウザで次のURLにアクセスします。

http://localhost:18088/v1.0/invoke/oauth-app/method/hello

GitHubに未ログインの場合は、GitHubのログインフォームが表示されます。

f:id:cero-t:20211209061716p:plain:h640
GitHubのログイン画面

さらにログイン後には認可を求める確認画面が表示されます。

f:id:cero-t:20211209061737p:plain:h480
GitHubの認可確認画面

ここで「Authorize」を選択すると、作成したアプリケーションに戻り、次のJSONが表示されるはずです。

{
  "message": "Hello, world!"
}

期待通り、Hello, worldのメッセージを取得できました。

ブラウザからアクセスしてトークンを確認

もう一つのエンドポイントにもアクセスしてみましょう。ブラウザで次のURLにアクセスします。

http://localhost:18088/v1.0/invoke/oauth-app/method/token

次のようなJSONが表示されます。

{
  "x-oauth-token": "Bearer gh*_********"
}

これはGitHubにアクセスする際に必要となるトークンです。これをどう使うかは後で説明するとして、ひとまずGitHubのOAuthが成功してトークンを取得できたことが分かりました。

curlコマンドでアクセス

続いて、curlコマンドでもアクセスしてみましょう。

curl -i localhost:18088/v1.0/invoke/oauth-app/method/hello

次のようなレスポンスが返ってきます。

HTTP/1.1 302 Found
Server: fasthttp
Date: Wed, 09 Dec 2021 00:00:00 GMT
Content-Length: 0
Location: https://github.com/login/oauth/authorize?access_type=offline&client_id=********&redirect_uri=http%3A%2F%2Flocalhost%3A18088&response_type=code&scope=&state=********
Traceparent: 00-0147c54031ce7f17b09f38840c8ec3be-7a79984201eba8b1-00
Set-Cookie: gosessionsid=********; expires=Wed, 09 Dec 2021 02:00:00 GMT; path=/; HttpOnly

GitHubのログイン画面に対するリダイレクトのレスポンスです。このリダイレクトに従って、GitHubのログイン画面に遷移しているわけです。

ちなみに、クッキーに gosessionsid というセッションIDを保存しているようですね。

また、Daprではなく、アプリケーションに直接アクセスした場合はどうなるかも確認しましょう。次のコマンドでアクセスしてください。

curl localhost:8088/hello

次のメッセージが取得できたはずです。

{"message":"Hello, world!"}

OAuthも何もなくアクセスできてしまいました。

OAuthはあくまでもDaprの設定で行っているものであり、Webアプリケーション側に直接アクセスした場合は何の確認もなくアクセスできるのです。その辺りをどう設計すべきか、どうやってアクセスするルートを絞るかなどは別のテーマとなるため、ここでは説明を割愛したいと思います。

トークンを使ってGitHubにアクセスする

ここまでで、アクセス時にGitHub側での認証と認可を行い、アプリケーション側でGitHubトークンを手に入れることができました。ただ、アクセスしたユーザが誰かという情報はありません。もしOAuthを認証代わりに利用するなら、アクセスしてきたのが誰かということを知る必要があります。その実装をしてみましょう。

※そもそもそれならOpenID Connectを使う方が良いのでしょうけど、Dapr単体でOpenID Connectを使って外部のサーバとID連携するのが難しかったので、OAuthを使いました。

コントローラークラスにGitHubにアクセスするメソッドを追加

GitHubで発行されたトークンは、リクエストヘッダの x-oauth-token でアプリケーションに渡されます。このトークンを使えばGitHubにアクセスすることができるので、GitHubにアクセスするメソッドをコントローラークラスに追加しましょう。

OAuthController.java(抜粋)

@Value("https://api.github.com/user")
private String userApi;

@GetMapping("/user")
public Map<?, ?> user(@RequestHeader("x-oauth-token") String oauthToken) {
    HttpHeaders httpHeaders = new HttpHeaders();
    httpHeaders.set("Authorization", oauthToken);
    HttpEntity<?> request = new HttpEntity<>(httpHeaders);

    return restTemplate.exchange(userApi, HttpMethod.GET, request, Map.class).getBody();
}

GitHubのUser APIにアクセスしてユーザ情報を取得する処理を追加しました。Authorization ヘッダに x-oauth-tokenトークンをそのまま渡しています。

アプリケーションの再起動

アプリケーションをいったん止めて、再起動します。

dapr run --app-id oauth-app --app-port 8088 --dapr-http-port 18088 --components-path ./components --config config.yaml ../mvnw spring-boot:run

アプリケーションが起動したら、次のURLにブラウザでアクセスします。

http://localhost:18088/v1.0/invoke/oauth-app/method/user

次のようなJSONが表示されるはずです。

{
  "login": "cero-t",
  "id": 1438519,
  "node_id": "****",
  "avatar_url": "https://avatars.githubusercontent.com/u/1438519?v=4",
  "gravatar_id": "",
  "url": "https://api.github.com/users/cero-t",
  "html_url": "https://github.com/cero-t",
  "followers_url": "https://api.github.com/users/cero-t/followers",
  "following_url": "https://api.github.com/users/cero-t/following{/other_user}",
  "gists_url": "https://api.github.com/users/cero-t/gists{/gist_id}",
  "starred_url": "https://api.github.com/users/cero-t/starred{/owner}{/repo}",
  "subscriptions_url": "https://api.github.com/users/cero-t/subscriptions",
  "organizations_url": "https://api.github.com/users/cero-t/orgs",
  "repos_url": "https://api.github.com/users/cero-t/repos",
  "events_url": "https://api.github.com/users/cero-t/events{/privacy}",
  "received_events_url": "https://api.github.com/users/cero-t/received_events",
  "type": "User",
  "site_admin": false,
  "name": "Shin Tanimoto",
  "company": null,
  "blog": "https://cero-t.hatenadiary.jp/",
  "location": null,
  "email": null,
  "hireable": null,
  "bio": null,
  "twitter_username": "cero_t",
  "public_repos": 44,
  "public_gists": 4,
  "followers": 54,
  "following": 0,
  "created_at": "2012-02-15T02:14:10Z",
  "updated_at": "2021-09-22T04:04:10Z"
}

GitHubのユーザ情報が取得できました。これでアプリケーション側で、アクセスしてきたユーザが誰なのかを把握することができますね。

ブラウザはDaprに何を送っているのか?

ところで、Daprにアクセスする際に、ブラウザはどういう情報をDaprに送っているのでしょうか。GitHubトークンを毎回送っているはずもないので、最後にそれを確認しておきましょう。

まずブラウザの開発者ツールを表示します。たいていキーボードのF12とか、Alt + Command + Iとかで開きます。

開発者ツールを開いたら、次のURLにアクセスします。

http://localhost:18088/v1.0/invoke/oauth-app/method/hello

開発者ツールのネットワークタブに hello へのアクセスなどが表示されるはずです。

f:id:cero-t:20211209064047p:plain
ネットワークタブ

この hello の詳細を開き、下の方にある Request Headers を見てください。

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: max-age=0
Connection: keep-alive
Cookie: gosessionsid=********
Host: localhost:18088
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="96", "Google Chrome";v="96"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36

ここで Cookie: gosessionsid= にある通り、クッキーに入ったセッションIDをDaprに送っていることが分かります。Daprがログイン状態をセッションに保存しているのだろうと推測できます。

むろんブラウザがDaprにアクセスする際に x-oauth-token に入っていたようなGitHubのアクセストークンを毎回送るというのも変な話ですから、このようなセッションIDを使うのも当然ですよね。もしセッションではなく、トークンなどを使ってステートレスに認証認可を行いたいのであれば、OpenID ConnectのJWTトークンを使うべきなのでしょうね。

まとめ

  • Daprを使ってOAuthの認可をすることができます
  • 主要なクラウドサービスのOAuthサーバを利用することができます
  • コンポーネント設定ファイルに利用するOAuthサーバのClientIDなどを記述します
  • パイプライン設定ファイルにそのOAuthコンポーネント設定を利用することを記述します
  • OAuthサーバにアクセスするためのトークンを、アプリケーションのHTTPヘッダで受け取ることができます
  • アプリケーションとDapr間でセッションを使って認証認可の状態を維持しているようです

正直、OAuthとOpenID Connectの違いもよく分かってなかったのですが、Daprでいろいろ試してみて、OAuthはあくまでもOAuthサーバ(今回であればGitHub)のAPIを利用するために使うものなんだなと理解しました。一方、OpenID Connectであれば認証/認可後にJWTトークンを用いるため、認証状態をステートレスに維持できますし、このJWTトークンからユーザ情報も取得できるため、目的がアプリケーションの認証なのであればOpenID Connectを使うほうが良いのでしょうね。

その理解が合っているかどうかあまり確信はないので、教えて詳しい人! という感じです。

そんなわけで、また明日!

Dapr Advent Calendar 8日目 - DaprとZipkinで分散トレーシング

こんにちは、Dapr Advent Calendar 8日目です。今週は会社で全エンジニアとの1 on 1などやっており、なかなかの忙しさなのですが、何とかブログを続けられています!

DaprとZipkinで分散トレーシングをしてみよう

今回はDaprとZipkinを使って分散トレーシングをしてみます。最近色々なサービスやミドルウェアが分散トレーシングに対応するようになったので、だいぶ世の中に認知される技術になりましたよね。使ってるかどうかは別として😏

分散トレーシングやZipkinについての解説をすると長くなるので、あまりご存じないという方は先に調べておいてくださいね。

今回作成するアプリケーションのソースコードgithubに置いてあります。

https://github.com/cero-t/dapr-advent-2021/

今回は、過去に作成した「hello」「publish」と「subscribe」モジュールを使い、また「tracing」モジュールを作成します。

Daprで分散トレーシングを使ってみる

いきなり分散トレーシング

まずはアクセスしてみる

いきなりですが、Advent Calendar 2日目で作ったHello Worldのアプリケーションを起動させます。

cd hello
dapr run --app-id hello-app --app-port 8080 --dapr-http-port 18080 ../mvnw spring-boot:run

起動したら、Dapr経由でアプリケーションにアクセスしてみます。

curl localhost:18080/v1.0/invoke/hello-app/method/hello

メッセージが表示されるはずです。

{"message":"Hello, world!"}

特に問題なくアクセスができたでしょう。

f:id:cero-t:20211202051328p:plain
いま呼び出したアプリケーション

Zipkinにアクセス

そしたら、ブラウザで次のURLにアクセスしてみます。

http://localhost:9411/

そうするとZipkinの画面が表示されるので、右上の方にある「RUN QUERY」ボタンを押すと、何やら一覧が出てきます。

f:id:cero-t:20211208062840j:plain
Zipkinのトレース一覧画面

3つ並んでいるうちの一番上の「SHOW」ボタンを押してみます。

f:id:cero-t:20211208062934p:plain
Zipkinのトレース詳細画面

何やら結果が表示され、いま行ったリクエストが記録されていることが分かりました。

Daprを起動しただけなのに、Zipkinにアクセスできるし、トレーシングも行われている様子が分かります。まずはこの環境から説明します。

Zipkinと分散トレーシングは最初から設定されている

Dapr Advent Calendar 1日目に、Dapr CLIを使って dapr init をすると設定ファイルとDockerコンテナがいくつかできるという説明をしました。

上でアクセスした localhost:9411 は、ここで作成されたzipkinのDockerコンテナです。Daprのローカル環境では、最初から分散トレーシングを行えるようZipkinがインストールされるのです。

また、dapr init で同じように作成された ~/.dapr/config.yaml も見てみましょう。

~/.dapr/config.yaml

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: daprConfig
spec:
  tracing:
    samplingRate: "1"
    zipkin:
      endpointAddress: http://localhost:9411/api/v2/spans

設定ファイルの詳細はあとで説明しますが、ひとまず spec.tracing という設定が行われていることが分かります。

dapr run コマンドでアプリケーションを起動する際に、--config オプションでこの設定ファイルを指定することができますが、指定しない場合には ~/.dapr/config.yaml が参照されるようになっています。

つまりローカル環境で dapr init を行うと、zipkinと ~/.dapr/config.yaml が作成されて、分散トレーシングが行われるように設定されるのです。これまで作ってきたアプリケーションたちも、気づかないうちに裏でzipkinにリクエストが記録されていたのです。

もう少し分散トレーシングらしいアプリケーションを作る

Hello Worldを呼ぶコントローラークラスの作成

Hello Worldを直接呼ぶだけでは分散トレーシングをしている感じがしないため、せめてHello Worldを呼び出すアプリケーションを別に作って、まとめて分散トレーシングをしてみましょう。

(tracing) TracingController.java

@RestController
public class TracingController {
    private RestTemplate restTemplate;

    @Value("http://localhost:${DAPR_HTTP_PORT}/v1.0/invoke/hello-app/method")
    private String helloUrl;

    public TracingController(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @GetMapping("/invokeHello")
    public Map<String, ?> invokeHello() {
        Map<?, ?> result = restTemplate.getForObject(helloUrl + "/hello", Map.class);
        return Map.of("baseUrl", helloUrl, "remoteMessage", result);
    }
}

呼び出す処理のエンドポイントを /invokeHello としました。Advent Calendar 3日目に作成したものと同じですね。

ポート番号をアプリケーション設定ファイルで指定

続いて、設定ファイルでアプリケーションの起動ポートを指定します。

(tracing) application.properties

server.port=8087

このアプリケーションは8087番ポートを利用します。

Daprを使ったアプリケーションの起動

それではDaprを使ってアプリケーションを起動します。

dapr run --app-id tracing-app --app-port 8087 --dapr-http-port 18087 ../mvnw spring-boot:run

アプリケーションが起動したら次のコマンドでアクセスしてみます。

curl localhost:18087/v1.0/invoke/tracing-app/method/invokeHello

次のような結果が表示されるはずです。

{"remoteMessage":{"message":"Hello, world!"},"baseUrl":"http://localhost:18087/v1.0/invoke/hello-app/method"}

問題なくアクセスができました。

f:id:cero-t:20211208064331p:plain
いま呼び出したアプリケーション

Zipkinで確認する

いま行ったアクセスを確認するために、ブラウザでZipkinにアクセスします。

http://localhost:9411/

表示結果を時系列にするために「Start Time」をクリックして時間の降順に並べるようにしました。

f:id:cero-t:20211208064849j:plain
Zipkinのトレース一覧画面

結果を見ると、/v1.0/metadata へのアクセス以外に、ほぼ同じ時刻に tracing-app calllocal/hello-app/hellotracing-app calllocal/tracing-app/invokehello の2つが並んでいます。前者は「tracing-appからhello-appの/helloを呼んだ」という記録で、後者が「tracing-appの/invokeHelloが呼ばれた」という記録です。

分散トレーシングは、こういうものを1つにまとめる仕組みなのに、分かれているのはおかしいですね。なぜでしょうか。

トレースIDの伝播

複数のプロセスにまたがった分散トレーシングを行うためには「どのリクエストに紐付く処理なのか」を示すためのIDをリクエスト間で伝播させる必要があります。このIDはトレースIDとか、相関ID(correlation-id)と呼ばれています。

分散トレーシングを経験した人ほど、このトレースIDをどう伝播させるかに悩まされていると思うのですが、Daprの公式ドキュメントでもその辺りはきちんと触れられておらず、意外と話題にならないのが不思議です。

このトレースIDは様々な仕様や流派があるのですが、Daprでは W3C Trace-Context に準拠した traceparent が使われています。ちなみにDapr 0.6あたりまでは x-correlation-id という独自のトレースIDを利用しました。

traceparentを伝播させるようコントローラークラスを修正

それでは、この traceparent を伝播させるようなメソッドをコントローラークラスに追加しましょう。

(tracing) TracingController.java(抜粋)

    @GetMapping("/invokeHello2")
    public Map<String, ?> invokeHello(@RequestHeader("traceparent") String traceparent) {
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.set("traceparent", traceparent);
        HttpEntity<?> request = new HttpEntity<>(httpHeaders);

        Map<?, ?> result = restTemplate.exchange(helloUrl + "/hello", HttpMethod.GET, request, Map.class).getBody();
        return Map.of("baseUrl", helloUrl, "remoteMessage", result);
    }

Daprからリクエストヘッダで受け取った traceparent を取り出して、次のリクエストを呼ぶ際にリクエストヘッダに追加しています。RestTemplateの利用するメソッドが getForObject から exchange に変わりましたが、getForObject メソッドにはリクエストヘッダを渡す機能がないので、exchange メソッドを使っています。

このメソッドは /invokeHello2 というエンドポイントにしました。

Daprを再起動して、Zipkinで確認

それでは一度アプリケーションを停止させ、Daprを使ってアプリケーションを再起動します。

dapr run --app-id tracing-app --app-port 8087 --dapr-http-port 18087 ../mvnw spring-boot:run

アプリケーションが起動したら次のコマンドでアクセスしてみます。

curl localhost:18087/v1.0/invoke/tracing-app/method/invokeHello2

次のような結果が表示されるはずです。

{"remoteMessage":{"message":"Hello, world!"},"baseUrl":"http://localhost:18087/v1.0/invoke/hello-app/method"}

いま行ったアクセスを確認するために、ブラウザでZipkinにアクセスします。

http://localhost:9411/

今度はリクエストが1つにまとめられるようになりました。

f:id:cero-t:20211208065241j:plain
Zipkinのトレース一覧画面

詳細を見ると、3つのリクエストが1つにまとまっていることが分かります。

f:id:cero-t:20211208065329p:plain
Zipkinのトレース詳細画面

上から順に「tracing-appの/invokeHello2が呼ばれた」「tracing-appからhello-appの/helloを呼んだ」「hello-appの/helloが呼ばれた」という処理になっています。この3つの記録は、ちょうどDaprを通過した場所とも一致しますね。

f:id:cero-t:20211208065611p:plain
Daprを通過するリクエス

Daprを通過するところで分散トレーシングを行っているということが分かりました。

pubsubでも分散トレーシング

ここまではInvoke APIを利用した処理で分散トレーシングを行いましたが、キューを経由するような処理でも分散トレーシングができるかどうかを試してみましょう。いったんキューを経由するため、Daprで保持していたような情報が失われるかも知れませんよね。

publishのコントローラークラスを修正

まずは以前のエントリーで作成したpublishモジュールの PublishController に、トレースIDを伝播するようなメソッドを追加します。

(publish) PublishController(抜粋)

    @PostMapping("/publish2")
    public void publish(@RequestBody Object message, @RequestHeader("traceparent") String traceparent) {
        System.out.println(traceparent);

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.set("traceparent", traceparent);
        HttpEntity<?> request = new HttpEntity<>(message, httpHeaders);

        restTemplate.exchange(pubsubUrl, HttpMethod.POST, request, Void.class);
    }

上で作成した invokeHello2 と同様の方法で、トレースIDである traceparent を伝播させています。また確認するためにトレースIDを標準出力に出力しています。このメソッドは /publish2 というエンドポイントにしました。

続いて、tracingモジュール側に、これを呼び出すためのメソッドを追加します。

(tracing) TracingController.java(抜粋)

    @Value("http://localhost:${DAPR_HTTP_PORT}/v1.0/invoke/publish-app/method")
    private String publishUrl;

    @PostMapping("/invokePublish")
    public void invokePublish(@RequestBody Object message, @RequestHeader("traceparent") String traceparent) {
        System.out.println(traceparent);

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.set("traceparent", traceparent);
        HttpEntity<?> request = new HttpEntity<>(message, httpHeaders);

        restTemplate.exchange(publishUrl + "/publish2", HttpMethod.POST, request, Void.class);
    }

上で作成した /publish2traceparent を伝播させながら呼び出しています。こちらでもトレースIDを標準出力に出力しています。こちらは /invokePublish というエンドポイントにしました。

Daprを使ったアプリケーションの起動

それでは作成したアプリケーションをDaprを使って起動させましょう。

まずはtracingアプリケーションをいったん停止させ、再起動します。

dapr run --app-id tracing-app --app-port 8087 --dapr-http-port 18087 ../mvnw spring-boot:run

続いて、publishアプリケーションを起動します。

cd publish
dapr run --app-id publish-app --app-port 8083 ../mvnw spring-boot:run

さらに、subscribeアプリケーションを起動します。

cd subscribe
dapr run --app-id subscribe-app --app-port 8084 ../mvnw spring-boot:run

pubsubは今回は標準で用意されたRedisを使うため、特に components の指定などはしていません。

f:id:cero-t:20211208070016p:plain
ここで起動したアプリケーション

アクセスして分散トレーシングの結果を見てみる

それでは起動したアプリケーションにアクセスしてみましょう。

curl -XPOST "localhost:18087/v1.0/invoke/tracing-app/method/invokePublish" -H "Content-type:application/json" -d '{
  "name": "Shin Tanimoto",
  "twitter": "@cero_t"
}'

そしてzipkinにアクセスしてみます。

http://localhost:9411/

いま送ったリクエストが一つにまとまって出力されているはずです。

f:id:cero-t:20211208070451p:plain
invokePublishのトレース詳細

tracing-appからpublish-appを経由してsubscribe-appまで一連の処理が流れたことがよく分かります。

また、tracingアプリケーション、publishアプリケーションそれぞれのコンソールには、トレースIDが出力されているはずです。

== APP == 00-8c991c3d443d34906f76f86a1b55fc03-205e7c2adf46ae1b-01

そして、subscribeアプリケーションのコンソールには、次のようなメッセージが出力されるはずです。

== APP == {"specversion":"1.0","source":"publish-app","type":"com.dapr.event.sent","traceid":"00-8c991c3d443d34906f76f86a1b55fc03-6d547142bb715869-01","data":{"name":"Shin Tanimoto","twitter":"@cero_t"},"id":"8318fa00-c587-46fc-aba6-53513feac2c6","datacontenttype":"application/json","topic":"my-message","pubsubname":"pubsub"}

このうち traceid のところに、同じトレースIDが出力されているはずです。どうやらpubsubではこの traceid という項目で traceparent を伝播させるということが分かりましたね。

同期処理だけではなく、非同期のpubsubでも問題なく分散トレーシングができることが分かりました。

今回やったことの解説

それでは、Daprがどのように分散トレーシングを行うかの仕組みについて説明します。

Daprの分散トレーシングに関するドキュメントはここにあります。

docs.dapr.io

Daprによる分散トレーシングの概要

f:id:cero-t:20211208070753p:plain
Tracing Overview

公式ドキュメントの図に示されているようにDaprの中でTracing Middlewareが動いており、Daprをリクエストが経由するたびに、Tracing Backend(今回はzipkin)にその情報が蓄積されるようになっています。逆に言えば、Daprを経由しないアクセスでは情報は蓄積されません。

上では、Daprを経由する形でアクセスしたため、Zipkinに情報が記録されました。

curl localhost:18080/v1.0/invoke/hello-app/method/hello

ここで、Daprを経由せずに直接アプリケーションにアクセスすると

curl localhost:8080/hello

何度アクセスしても、Zipkinには情報が記録されません。あくまでもDaprを経由したリクエストのみがZipkinに記録されるのです。

DaprのMiddleware機能

上で「Tracing Middleware」と簡単に言いましたが、このMiddlewareについてもう少し詳しく説明します。

Middlewareのドキュメントはここにあります。

docs.dapr.io

f:id:cero-t:20211208070856p:plain
Middleware Overview

上の画像はMiddlewareのドキュメントのものです。Daprは外部からリクエストを受け、アプリケーションを呼び出す際に、Middlewareをいくつか通過するというパイプライン処理を行っていることが分かります。

Middlewareは共通的な処理を行うフィルタのようなもので、Javaの開発者であれば、ServletFilterやInterceptorのようなものだと捉えれば分かりやすいと思います。このMiddlewareのひとつがTracing Middlewareです。

Middlewareには他にも認証認可を行うものやレートリミット(アクセス数の制限)を行うものなどがあります。Middlewareの一覧はここにあります。

https://docs.dapr.io/reference/components-reference/supported-middleware/

この一覧には掲載されていませんが、Daprでは「Tracing Middleware」と「CORS Middleware」が最初から組み込まれています。

Daprのパイプライン設定ファイル

このパイプライン設定をするのが config.yaml です。今回は dapr init で作成されたものをそのまま使いました。

~/.dapr/config.yaml

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: daprConfig
spec:
  tracing:
    samplingRate: "1"
    zipkin:
      endpointAddress: http://localhost:9411/api/v2/spans

spec.tracing が分散トレーシングの設定です。

samplingRate はDaprを経由するリクエストのうち何割をTracing Backend(zipkin)に送るかを指定するものです。分散トレーシングはたいていパフォーマンス測定に使われるため、わざわざ全リクエストを送る必要はなく、たとえば 0.010.1 などを指定して一部のリクエストだけ記録することで、分散トレーシングそのものがアプリケーションに負荷を掛けないようにします。

ただし、分散トレーシング機能そのものを確認したい場合や、登壇でデモをする場合などは、常に記録されていたほうが嬉しいので 1 を指定したほうが良いでしょう。また、分散トレーシングをパフォーマンス計測ではなく、たまに発生する障害の分析などに使いたい場合も 1 を指定すると良いと思います。その辺りは目的や負荷とのバランスで決定します。

zipkin.endpointAddress はZipkinサーバのアドレスを指定します。Zipkinだけでなく、ZipkinとAPI互換のあるJaegerやNew Relicなどのアドレスを指定することもできます。

パイプライン設定ファイルは、これまで使ってきたコンポーネント設定ファイル(/components に置くファイル)とは異なり、1つしかありません。Daprにどのようなパイプライン処理をさせるかをまとめてここに記載するため、1つしか指定できないのです。

このパイプラインの設定をすることで、分散トレーシングが有効になりました。

まとめ

  • Daprは最初からzipkinを使った分散トレーシングが有効になっている
  • 複数のアプリケーション間での分散トレーシングをするためには、traceparent というヘッダを伝播させる必要がある
  • Invoke APIでもPub/sub APIでも分散トレーシングが使える
  • DaprにはMiddlewareというパイプライン処理を行うための仕組みがある
  • 分散トレーシングはTracing Middlewareという機能で提供されている
  • ローカル環境で dapr init するとZipkinのDockerコンテナが起動し、分散トレーシングを有効にするためのパイプライン設定ファイルが作成されるため、最初から分散トレーシングが有効になる

ところで、今回は分かりやすさのため、トレースIDを伝播させる実装をそれぞれのメソッドに追加しました。実際にはこんなものを全メソッドに入れるのは無理筋ですよね。このような処理は共通的に実装するべきで、たとえばSpring BootであればRestTemplateのInterceptor機能や、WebClientのFilter機能を使って実装ができます。その辺りについてはDaprの範疇ではなくSpring Bootの範疇となるため、今回の説明からは割愛しました。

また機会があれば、この辺りの実装についても紹介したいと思います。

それでは、また明日!

Dapr Advent Calendar 7日目 - Daprでシークレットストアを使う

こんにちは、Dapr Advent Calendar 7日目です。とうとう1週間続きました! これでもまだ全体の1/3も行ってないんですからね。先は長い!

Daprでシークレットストアを使ってみよう

今回はDaprを使って、パスワードなどを秘匿するシークレットストアを利用します。シークレットストアと言えば、AWS Secrets ManagerやAzure Key Vault、あるいはk8sのsecrets機能などがありますが、これらをDaprから利用することができます。

f:id:cero-t:20211207053457p:plain
今回作るアプリケーション

シークレットについてのドキュメントはこちらにあります。

docs.dapr.io

Daprからシークレットを利用する(読み取る)ことはできますが、シークレットを作成する(書き込む)機能はないため、シークレットは先に自分で作っておく必要があります。

Daprがサポートしているシークレットストアはここにまとめられています。

docs.dapr.io

今回は、最も簡単にできるローカルファイルを利用しましょう。

今回作成するアプリケーションのソースコードgithubに置いてあります。

https://github.com/cero-t/dapr-advent-2021/tree/main/secrets

シークレットを取得するためのアプリケーション作成

コントローラークラスの作成

まずはWebアプリケーションの作成です。シークレットを取得するコントローラーを作成します。

SecretsController.java

@RestController
public class SecretsController {
    private RestTemplate restTemplate;

    @Value("http://localhost:${DAPR_HTTP_PORT}/v1.0/secrets/my-secret")
    private String secretsUrl;

    public SecretsController(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @GetMapping("/{key}")
    public String read(@PathVariable String key) {
        return restTemplate.getForObject(secretsUrl + "/" + key, String.class);
    }
}

DaprのSecrets APIを呼び出しているだけの簡単な処理です。

Secrets API/v1.0/secrets/(シークレットストア名)/(シークレットのキー) という形式になります。シークレットストア名は my-secret として、シークレットのキーはリクエストのパスで渡されたものをそのまま使います。

ポート番号をアプリケーション設定ファイルで指定

続いて、設定ファイルでアプリケーションの起動ポートを指定します。

application.properties

server.port=8086

このアプリケーションは8086番ポートを利用します。

シークレットストアを参照するためのDapr設定ファイルを作成

さらに、シークレットストアを参照するためのDapr設定ファイルを作成します。

components/my-secret.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: my-secret
  namespace: default
spec:
  type: secretstores.local.file
  version: v1
  metadata:
  - name: secretsFile
    value: ./components/my-secret.json

metadata.name はこのコンポーネントの名称です。この値がSecrets APIのシークレットストア名として利用されるため、上のソースコードと同じ /my-secret を指定しています。

typesecretstores.local.file を指定しており、これはシークレットストアとしてローカルファイル(平文のJSON)を使うという指定です。

secretsFile はシークレットストアに利用するローカルファイルのファイル名です。このyamlファイルと同じ場所に置きたいので ./components/my-screts.json を指定しました。本来、シークレットファイルはもう少し安全な場所に置くべきかも知れませんが、ローカル開発環境のパスワードなどは雑に共有することも多いでしょうから、あまり気にせずここに置くことにしました。

もう少し慎重を期するなら、ユーザーディレクトリに .dapr/secrets ディレクトリなどを作成して他のユーザーに読み取れないようにするのが良いと思います。

シークレットファイルの作成

最後に、シークレットストアに使うファイルを作成します。

components/my-secret.json

{
  "password1": "p@ssw0rd",
  "password2": "12345678"
}

世界で最も著名なパスワード2種を指定しました。

Daprを使ったアプリケーションの起動

それではDaprを使ってアプリケーションを起動します。

dapr run --app-port 8086 --components-path ./components ../mvnw spring-boot:run

アプリケーションが起動したら次のコマンドでアクセスしてみます。

curl localhost:8086/password1

結果が取得できます。

{
  "password1": "p@ssw0rd"
}

もう一つのパスワードも取得してみましょう。

curl localhost:8086/password2

問題なく取得できるはずです。

{
  "password1": "12345678"
}

ここまでで作成したアプリケーションでは平文のパスワードが書かれたjsonファイルを読み込んだだけで、シークレット感も何もありませんね。実際に運用する際にはDaprの設定を変えて、k8sのsecretsやクラウドサービスのシークレットストアを利用する形になるでしょう。

f:id:cero-t:20211207053523p:plain
Daprを使ってシークレットストアにアクセス

Daprの設定にシークレットを使う

上の例はシークレットの値をそのままレスポンスとして返すものでしたが、実際のアプリケーションでそんな利用をすることもないでしょう。

シークレットを使うユースケースは、データベースへの接続パスワードを秘匿するとか、外部のWebサービスのアカウント情報を秘匿するといったものが多いと思います。それに近い例として、Daprからデータストアに接続する情報をシークレットから取得するという方法を試してみます。

Daprの設定ファイルでシークレットを利用する方法についてのドキュメントはここにあります。

docs.dapr.io

このドキュメントで指定されている方法を試してみましょう。

データストアにアクセスするコントローラークラスの作成

以前のエントリーで作成したデータストアにアクセスするコントローラーを利用します。

StateController.java

@RestController
public class StateController {
    private RestTemplate restTemplate;

    @Value("http://localhost:${DAPR_HTTP_PORT}/v1.0/state/postgresql-store")
    private String stateUrl;

    public StateController(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @PostMapping("/write")
    public void write(@RequestBody Object message) {
        restTemplate.postForObject(stateUrl, message, Void.class);
    }

    @GetMapping("/read/{key}")
    public Object read(@PathVariable String key) {
        return restTemplate.getForObject(stateUrl + "/" + key, Object.class);
    }
}

完全にコピペですが、データストア名だけ分かりやすいように postgresql-store にしています。

シークレットストアのDapr設定ファイルを作成

次に、シークレットストアの設定ファイルを作ります。

components/postgresql-secret.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: postgresql-secret
  namespace: default
spec:
  type: secretstores.local.file
  version: v1
  metadata:
  - name: secretsFile
    value: ./components/postgresql-secret.json

上で作成したシークレットの設定ファイルと同様ですね。

続いて、シークレットストアとして利用するファイルを作りましょう。

components/postgresql-secret.json

{
  "connectionString": "host=localhost user=postgres password=secretpassword port=5432 connect_timeout=10 database=postgres"
}

PostgreSQLに接続する情報をすべて記述しています。この接続文字列も以前のエントリーで指定したものと同じです。

本来はパスワードだけシークレットを利用したかったのですが、DaprのPostgreSQL設定ファイルでは接続文字列をすべて指定する形になるため、接続文字列全体をシークレットに保存することにしました。

データストアのDapr設定ファイルを作成

最後に、データストアに接続するための設定ファイルを作成します。この接続設定がシークレットストアを参照するようにします。

components/postgresql-store.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: postgresql-store
spec:
  type: state.postgresql
  version: v1
  metadata:
  - name: connectionString
    secretKeyRef:
      name: connection
      key: connection
auth:
  secretStore: postgresql-secret

これも以前作ったものと同様ですが connectionString にシークレットを使うように設定しました。

auth.secretStore でシークレットストア名を指定します。上で作成したシークレットストア設定の postgresql-secret を使いました。

connectionStringsecretKeyRef を指定してシークレットストアを参照できるようにしています。ここで namekey の両方を connection としていますが、シークレットが単一の文字列である場合は、このように namekey の両方に同じキー名を指定します。シークレットがJSONになっている場合には、name にシークレットキーを指定し、keyJSONのキーを指定する形になります。

GitHubのサンプルコードではファイル名を postgresql-store.yaml.disabled として、起動時にPostgreSQLにアクセスしないようにしていました。こちらを利用する場合はファイル名を postgresql-store.yaml に変更してください。

これでアプリケーションの修正は完了です。

PostgreSQLの起動

まずはPosgreSQLを起動しましょう。Dockerを使って起動します。

docker run -d --name dapr_postgres -p 5432:5432 -e POSTGRES_PASSWORD=secretpassword postgres

もし以前のエントリーで起動した時のDockerコンテナが起動したままであれば、それをそのまま使っても構いません。

コンテナが残っていて停止していた場合は docker start dapr_postgres で起動してください。

Daprを使ったアプリケーションの(再)起動

それではDaprを使ってアプリケーションを起動します。いったん上で起動したアプリケーションを停止させた後、再起動してください。

dapr run --app-id secrets-app --app-port 8086 --components-path ./components ../mvnw spring-boot:run

State mangement APIapp-id ごとにデータを保存するので、お行儀良く app-id を指定しています。マナーです。

起動を確認できたら、次のコマンドでデータを登録します。

curl -XPOST "localhost:8086/write" -H "Content-type:application/json" -d '[
{
  "key": "cero_t",
  "value": {
    "name": "Shin Tanimoto",
    "twitter": "@cero_t"
  }
}
]'

登録ができたら、次のコマンドでデータを取得します。

curl localhost:8086/read/cero_t

登録したデータが取得できるはずです。

{
  "name": "Shin Tanimoto",
  "twitter": "@cero_t"
}

正しくPostgreSQLでデータの読み書きができることを確認できました。

PostgreSQLの接続設定をシークレットに保存することで、接続設定ファイルに平文のパスワードを書くことなく接続できるようになりました。この機能はどちらかと言えば開発時よりは運用時に効いてくる機能ですので、また今後のAdvent Calendarの中で良いユースケースと共に改めて紹介できればと思います。

f:id:cero-t:20211207053718p:plain
シークレットストアの情報を使ってPostgreSQLにアクセス

まとめ

  • Secrets APIを用いてシークレットストアから値の取得ができます
  • クラウドサービスやk8sの提供するシークレットストアに加え、ローカルファイルなどもシークレットストアとして利用できます
  • Daprのコンポーネント設定ファイルの中でもシークレットを利用することができます

ちなみに僕自身は、このDaprのSecrets機能を使ったことはなく(というか存在を認識しておらず)、k8sのSecrets機能を使ってDaprの設定をしていました。僕の場合はローカル環境でもk8sを使うか、あるいはそもそもk8sもDaprを使わずに開発するかのいずれかの方針を採っていたためです。

ちょうどその間の「k8sは使わないけど、Daprは使う」ような状況での開発だと、このSecrets機能は有用になりそうですね。その辺りの「どうやって開発するか?」みたいなところはまた今後のAdvent Calendarの中でテーマにしたいと思います。

それでは!

Dapr Advent Calendar 6日目 - DaprでTwitterにアクセス

こんにちは、Dapr Advent Calendar 6日目です。皆さん今日は仕事デース? 私も仕事デース!

Bindings機能を使ってTwitterを検索しよう

今回はDaprを使ってTwitterからツイートを取得します。そのためにDaprのBindings機能を利用します。

f:id:cero-t:20211206061851p:plain
今回作るアプリケーション

DaprのBindingsについてのドキュメントはこちらにあります。

docs.dapr.io

このドキュメントを最初に見た時、「Pub/subと何が違うんだ?」と思ったのですが、Pub/subは主に内部のメッセージブローカーに対するメッセージの送受信を行い、Bindingsは外部のサービスも含めたメッセージの送受信を行うもののようです。

Bindings機能がサポートしているコンポーネントはここにまとめられています。

docs.dapr.io

確かにメッセージブローカーやデータストアだけでなく、TwilioやTwitterのような外部サービスも掲載されていますね。公式ドキュメントのサンプルのようにKafkaを使うと「別にPub/subで良いじゃん」という気持ちになってしまうので、今回はTwitterを使ってみることにしました。

今回作成するアプリケーションのソースコードgithubに置いてあります。

https://github.com/cero-t/dapr-advent-2021/tree/main/binding

ツイートを取得するためのアプリケーション作成

コントローラークラスの作成

まずはWebアプリケーションの作成です。ツイートを受け取るコントローラーを作成します。

BindingController.java

@RestController
public class BindingController {
    @PostMapping("/receive")
    public void receive(@RequestBody Map<String, ?> message) {
        Map<String, String> tweet = extract(message);
        System.out.println("@" + tweet.get("screen_name") + " : " + tweet.get("text"));
    }

    private Map<String, String> extract(Map<String, ?> message) {
        Map<String, ?> user = (Map<String, ?>) message.get("user");
        String screenName = (String) user.get("screen_name");
        String name = (String) user.get("name");
        String text = (String) message.get("text");
        return Map.of("screen_name", screenName, "name", name, "text", text);
    }
}

この処理はちょうどPub/subのSubscriber側の実装に相当します。

新しいツイートが /receive に送られる想定です。receive メソッドはツイートから extract メソッドで必要な部分を取り出し、それを標準出力に出力しています。

extract メソッドは、Twitterから送られたメッセージのうち user.name user.screen_name text を取り出してMapに詰め直しています。静的型付け言語でMapを使って処理するとキャストばかりになって見づらくなりがちですね。

ポート番号をアプリケーション設定ファイルで指定

続いて、設定ファイルでアプリケーションの起動ポートを指定します。

application.properties

server.port=8085

このアプリケーションは8085番ポートを利用します。

Twitterと接続するためのDapr設定ファイルを作成

さらに、Twitterと接続するためのDapr設定ファイルを作成します。

components/twitter-binding.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: twitter-binding
spec:
  type: bindings.twitter
  version: v1
  metadata:
  - name: consumerKey
    value: "***"
  - name: consumerSecret
    value: "***"
  - name: accessToken
    value: "***"
  - name: accessSecret
    value: "***"
  - name: query
    value: "@cero_t"
  - name: route
    value: /receive

consumerKeyaccessSecret は、Twitter APIにアクセスするための設定です。これらのキーはTwitter Developerにアカウントを作成して、プロジェクトとアプリケーションを作成した後、トークンを発行する必要があります。

アカウントの作成方法やキーの発行方法は、こちらのサイトが分かりやすかったです。

blog.palettecms.jp

このサイトの「アプリの権限設定を行う」のところは行う必要がありませんので、その部分は飛ばしてしまっても大丈夫です。

consumerKeyconsumerSecret は、Twitter Developer上でアプリケーションを作成した際に発行された API keyAPI key secret の値をそれぞれ指定します。同時に発行された Bearer Token は利用しません。

accessTokenaccessSecret は、Twitter Developer上で発行した Access tokenAccess token secret の値をそれぞれ指定します。

queryTwitter検索のクエリです。今回は @cero_t として、僕に対するメンションを受け取るようにしました。

最後の route は受け取ったツイートを送るエンドポイントです。上のソースコードで指定した値と同じ /receive を指定します。なおこの項目を指定しなかった場合、エンドポイントは metadata.name の値が使用され /twitter-binding が呼ばれることになります。

これでアプリケーションの作成は完了です。

Daprを使ったアプリケーションの起動

それではDaprを使ってアプリケーションを起動します。

dapr run --app-port 8085 --components-path ./components ../mvnw spring-boot:run

これまで引数に入れていた app-id は今回は特に利用しないため、省略しました。

起動した後、Twitterでメンションを受けると、コンソールにツイートが表示されるはずです。自分で自分にメンションしても構いませんし、リプライをもらっても構いません。

== APP == @cero_t : テストです。 @cero_t

ちなみに軽い気持ちで「誰かリプしてみてください。」とつぶやいたところ、たくさんのリプをいただきました。

f:id:cero-t:20211206060147p:plain
コンソールに出力されたメッセージ

どうあれこれで、Daprを使ってTwitterと連携できたことが分かりました。TwitterAPIをよく知らずとも連携できるのは楽で良いですね。

f:id:cero-t:20211206061941p:plain
Daprを経由したツイートの受信

ここまでで作成したアプリケーションは、Twitterでイベントが発生した際、つまり、クエリに一致したツイートが新規に発生した際にDapr経由でそのツイートを受け取るものでした。この機能は「Input binding」と呼ばれています。

この名前から推測できるように、逆方向の「Output binding」もあります。次はそれを試してみましょう。

ツイートを検索するためのアプリケーション作成

早速Output binding側のアプリケーションを作成したいのですが、DaprのTwitter bindingのドキュメントを先に確認します。

docs.dapr.io

Output bindingの項には「get」の記載しかありません。どうやら現時点ではTwitterコンポーネントは「ツイートする」「DMを送る」というような機能は提供しておらず、「検索する」ことしかできないようです。アルファ版なので仕方ないところでしょうか。

本来であればメンションされた相手に自動的に返信する、というような機能を実装したかったところですが、仕方ないので今回はこの検索機能を使ってみましょう。Input bindingは受動的にツイートを受け取るものでしたが、Output bindingは能動的にツイートを取りに行くという点で、Input bindingとは逆方向であると考えてください。

コントローラークラスの作成

ツイートを検索するためのメソッドやフィールドを、先ほど作ったコントローラークラスに追加します。

BindingController.java(抜粋)

@RestController
public class BindingController {
    private RestTemplate restTemplate;

    @Value("http://localhost:${DAPR_HTTP_PORT}/v1.0/bindings/twitter-binding")
    private String bindingUrl;

    public BindingController(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @GetMapping("/search")
    public List<?> search(@RequestParam String query) {
        Map<String, ?> request = Map.of(
                "operation", "get",
                "metadata", Map.of("query", query,
                        "lang", "ja",
                        "result", "recent")
        );

        List<?> result = restTemplate.postForObject(bindingUrl, request, List.class);

        return result.stream()
                .map(e -> (Map<String, ?>) e)
                .map(this::extract)
                .collect(Collectors.toList());
    }
}

bindingUrl には、Binding APIのURLを指定します。URLは /v1.0/bindings/(binding名) となります。binding名は上で作成した twitter-binding が入ります。

search メソッドでは、Binding APIに対して次の形式のリクエストをPOSTしています。

{
  "data": "",
  "metadata": {
    "query": "twitter-query",
    "lang": "optional-language-code",
    "result": "valid-result-type"
  },
  "operation": "get"
}

metadata.query がツイートの検索クエリです。APIのクエリパラメータの query で渡された文字列をそのまま渡しています。

lang言語コードです。今回は日本語のみを対象にするので ja を指定しています。

resultpopular を指定すると日本語版Twitterで言うところの「話題」、recent を指定すると「最新」、mixed を指定するとその両方が取得できる、とのことですが、この辺りはTwitter側のAPIもよく変わるところなので、正直イマイチ分かりませんね。とりあえず今回は recent を指定しました。

最後の operation は、今のところ get しかサポートされていません。いずれここで post がサポートされればツイートできるようになるのだと思います。おそらく data もその時に使うのでしょう。

少し長くなりましたが、元のソースコードの説明に戻ると、そうやってツイート検索をした後、検索結果から user.name user.screen_name text をそれぞれ取り出してMapに詰め直してレスポンスとして返しています。

設定ファイルなどは上で作成したため、特に追加や変更はなく、これでアプリケーションの修正は完了です。

Daprを使ったアプリケーションの(再)起動

それではDaprを使ってアプリケーションを起動します。いったん上で起動したアプリケーションを停止させた後、再起動してください。

dapr run --app-port 8085 --components-path ./components ../mvnw spring-boot:run

起動を確認できたら、次のコマンドでメッセージを送ってみます。

curl "localhost:8085/search?query=@cero_t"

このエントリーを書いている時点では、次のようなメッセージが取得できました。

(前略)
  {
    "screen_name": "syobochim",
    "text": "@cero_t ワイワイ",
    "name": "しょぼちむインフィニティ🌊"
  },
  {
    "screen_name": "syobochim",
    "text": "@cero_t 何時に起きたんですか?!もしかして昼夜逆転ですか?!",
    "name": "しょぼちむインフィニティ🌊"
  },
  {
    "screen_name": "syobochim",
    "text": "@cero_t 朝ごはん食べましたか?!?!",
    "name": "しょぼちむインフィニティ🌊"
  },
  {
    "screen_name": "syobochim",
    "text": "@cero_t なにしてます?!?!",
    "name": "しょぼちむインフィニティ🌊"
  },
  {
    "screen_name": "syobochim",
    "text": "@cero_t 元気ですか?!?!",
    "name": "しょぼちむインフィニティ🌊"
  },
  {
    "screen_name": "syobochim",
    "text": "@cero_t おはようございます!!!",
    "name": "しょぼちむインフィニティ🌊"
  },
(後略)

ちょっとリプを送ってきすぎですよね。

どうあれこれでOutput bindingの確認もできました。先にも書いたとおり、ツイートをするのではなく検索をしているので方向が少し分かりづらいのですが、能動的にツイートを検索しに行っているという点で「アプリケーション → Twitter」のoutput方向であると考えてください。

いずれにせよTwitter APIの細かな仕様を知らなくとも、連携することができました。

f:id:cero-t:20211206063110p:plain
Daprを経由したツイートの検索

まとめ

  • Bindings APIを用いてメッセージブローカーや外部のサービスとの連携ができます
  • 受け取る方向のInput bindingと、送り出す方向のOutput bindingがあります
  • Pub/subとの違いは、主に外部サービスとの連携が可能なところです
  • TwitterのOutput bindingは今のところ検索しかサポートしていません
  • Twitterのやり過ぎには注意してください

これまでBindings APIを使ったことはなかったのですが、今回このエントリーを書くために試したところ、特にハマることもなくTwitterとの連携ができました。そのような機能をササッと実装できるのは良いところですね。

今後、様々な外部サービスの機能をもっとたくさん使えるようになれば、使いどころも増えてくるのではないかと思います。

そんな日を夢見ながら、また明日!

Dapr Advent Calendar 5日目 - Daprでメッセージング

こんにちは、Dapr Advent Calendar 5日目です。日曜日なのでゆっくりエントリーを書きました。

Pub/Subメッセージングをしてみよう

今回はDaprを使ってキューを使ったメッセージングをします。このメッセージング機能(Pub/sub API)とサービス呼び出し(Invoke API)の2つが、僕がDaprを使おうという決め手になったくらい重要機能だと思っています。

f:id:cero-t:20211205083946p:plain
今回作るアプリケーション

Pub/sub機能についてのドキュメントはこちらにあります。

docs.dapr.io

今回作成するアプリケーションのソースコードgithubに置いてあります。

https://github.com/cero-t/dapr-advent-2021/

今回は、このリポジトリにある「publish」と「subscribe」モジュールを作ります。

メッセージを送る側のアプリケーションの作成 (publish)

publish側のコントローラークラスの作成

まずはメッセージを送る側、つまりキューに対してエンキューする側のWebアプリケーション(publishモジュール)を作成します。

メッセージを送るために、DaprのPub/sub APIを使います。

(publish) PublishController.java

@RestController
public class PublishController {
    private RestTemplate restTemplate;

    @Value("${pubsubUrl}")
    private String pubsubUrl;

    public PublishController(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @PostMapping("/publish")
    public void publish(@RequestBody Object message) {
        restTemplate.postForObject(pubsubUrl, message, Void.class);
    }
}

publish メソッドにて、リクエストで受け取ったオブジェクトをそのまま pubsuburl(DaprのPub/sub APIのURL)に投げているだけです。

publish側のアプリケーション設定ファイルの作成

前回のエントリーではURLをソースコードに直書きしましたが、今回は環境への依存性を下げるためURLを設定ファイルに書くことにしました。

(publish) application.properties

server.port=8083
pubsubUrl=http://localhost:${DAPR_HTTP_PORT}/v1.0/publish/pubsub/my-message

このアプリケーションは8083番ポートを利用します。

Pub/sub APIのURLは /v1.0/publish/(pubsub名)/(メッセージトピック) となり、ここでは pubsub という名前のpubsubを使っています。なんかちょっと分かりづらいですね。

なぜこの名前にしたかというと、ローカル環境のDaprのデフォルトpubsub名がそうなっているためです。前回のエントリーでデータストアの設定について説明した際、設定ファイルがは~/.dapr/components/statestore.yaml にあると説明しました。

pubsubについても同様で、~/.dapr/components/pubsub.yaml に初期設定が記述されています。

~/.dapr/components/pubsub.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: pubsub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""

metadata.name がpubsubの名称です。要するにRedisをキューとして使う、という設定に pubsub という名称がついているのです。ここではひとまずこのデフォルト設定のものを使い、Redisを使うことにします。なお、このRedisは dapr init コマンドを実行した際に作成されたDockerコンテナに繋がるようになっています。

少しpubsub名の説明が長くなりましたが、元の話に戻りましょう。

Pub/sub APIのURLは /v1.0/publish/(pubsub名)/(メッセージトピック) でした。最後のメッセージトピックは、メッセージをルーティングするためのパスのようなものです。RabbitMQを利用したことがある人ならexchangeの名称として使われる、と言えば分かりやすいかも知れません。

今回は my-message という名前のメッセージトピックにしました。これが実際にどう使われるかは、後ほど説明します。

メッセージを受け取る側のアプリケーションの作成 (subscribe)

subscribe側のコントローラークラスの作成

続いてメッセージを受け取る側、つまりキューに対してデキューする側のWebアプリケーション(subscribeモジュール)を作成します。

メッセージを受け取る側は、通常のWeb APIとして作成します。

(subscribe) SubscribeController

@RestController
public class SubscribeController {
    @PostMapping("/subscribe")
    public void subscribe(@RequestBody String message) {
        System.out.println(message);
    }
}

メッセージを受け取って標準出力に出力するだけです。特にデキューする処理を書いたりせず、ただのWeb APIとして実装します。

subscribe側のアプリケーション設定ファイルの作成

このアプリケーションは8084番ポートを利用するため、設定ファイルにポート番号を書いておきます。

(subscribe) application.properties

server.port=8084

subscribe側のDapr設定ファイルの作成

さらに、上で作成したWeb APIでメッセージを受け取るように設定します。そのためにはDaprの Subscription 設定ファイルを作成する必要があります。このファイルは、少し抵抗があるかも知れませんが ~/.dapr/components/ ディレクトリに作成します。

~./dapr/components/subscription.yaml

apiVersion: dapr.io/v1alpha1
kind: Subscription
metadata:
  name: subscription
spec:
  pubsubname: pubsub
  topic: my-message
  route: /subscribe
scopes:
  - subscribe-app

metadata.name の値は特に利用しないため、任意の名前をつけて構いません。

pubsubname はpubsubの名称です。デフォルトで設定されていた pubsub を利用します。

topic にはメッセージトピックを指定します。ここではpublish側のアプリケーションで指定した my-message を指定しています。

route は、呼び出すWebアプリケーションのパスです。SubscribeController/subscribe というパスで待ち受けているため、それを指定します。

scopes には、待ち受けているアプリケーションのapp-idを指定します。今回はsubscribe側のアプリケーションを subscribe-app というapp-idで起動する予定ですので、それを指定しています。

ここに複数のアプリケーションのapp-idを列挙しておけば、同じメッセージを複数のアプリケーションで受け取れるようになるわけです。

GitHubのサンプルコードでは、このファイルを subscribe/.dapr/components/subscription.yaml に置いています。そちらを利用する場合は、ユーザーディレクトリにコピーしてください。

Daprを使ったアプリケーションの起動

それではDaprを使ってアプリケーションを起動します。まずは送る側のアプリケーション (publishモジュール) から起動します。

cd publish
dapr run --app-id publish-app --app-port 8083 ../mvnw spring-boot:run

引数などは特に説明する必要はないですね。

続いて受け取る側のアプリケーション (subscribeモジュール) を起動します。

cd subscribe
dapr run --app-id subscribe-app --app-port 8084 ../mvnw spring-boot:run

起動したら、publish側のAPIJSONでメッセージを渡します。

curl -XPOST "localhost:8083/publish" -H "Content-type:application/json" -d '{
  "name": "Shin Tanimoto",
  "twitter": "@cero_t"
}'

コマンドを実行したら、subscribe側のコンソールを見てください。次のようなメッセージが表示されているはずです。

== APP == {"id":"661aace2-dcbe-4d6e-be26-e4d4313d5f9c","source":"publish-app","type":"com.dapr.event.sent","pubsubname":"pubsub","specversion":"1.0","datacontenttype":"application/json","topic":"my-message","traceid":"00-016cf5eccd3ce47cc9bb85ba90742553-244bd4331034542d-01","data":{"name":"Shin Tanimoto","twitter":"@cero_t"}}

何やらたくさんのパラメータが表示されましたが、メッセージ本体が data に入っていることが確認できます。

CloudEvents 1.0について

ここで受け取ったメッセージには、メッセージ本体以外にもいくつかパラメータが追加されていますね。これらのパラメータはCloudEvents 1.0という仕様に準拠したものです。

github.com

CloudEvents 1.0ではいくつかパラメータが指定されており、この仕様に従えば他のミドルウェアとの相互運用性が上がって、Dapr以外のアプリケーションでも受信できるようになるとのこと。ただ正直、僕自身の経験としてはまだそのような使い方をしたことはありません。いつも取得したメッセージから data の部分のみを取り出しています。

CloudEventsなど不要という声が多いのでしょうか、Daprのサイトにも、CloudEventsを使わないためのドキュメントがあります。

docs.dapr.io

publishする際のURLに ?metadata.rawPayload=true というクエリパラメータを追加すればメッセージのみが送られるとのことです。

(publish) application.properties

server.port=8083
pubsubUrl=http://localhost:${DAPR_HTTP_PORT}/v1.0/publish/pubsub/my-message?metadata.rawPayload=true

このように設定ファイルを書き換えてアプリケーションを再起動し、再度メッセージを送信すれば、subscribe側のコンソールに次のようなメッセージが表示されるはずです。

== APP == {"name":"Shin Tanimoto","twitter":"@cero_t"}

特にCloudEventsの仕様に準拠させる必要がない場合には、この方法を使っても良いでしょう。

ここまででRedisを介したメッセージングを行うことができました。キュー処理もデキュー処理も、RedisのAPISDKを利用するのではなく、HTTP処理だけで書けることが分かりました。

f:id:cero-t:20211205084027p:plain
Daprを経由したメッセージング

異なるメッセージブローカーを使う

ここまではRedisを用いたメッセージングを行いましたが、他のメッセージブローカーを使うこともできます。

サポートされているメッセージブローカーの一覧はこちらにあります。

docs.dapr.io

ここではRabbitMQを使ってみることにします。まだRabbitMQ対応のコンポーネントはAlpha版のようですが、Dapr v1.5でデッドレターキュー(DLX)にも対応され、実際に運用しても特に問題は起きていません。

RabbitMQの起動

まずはRabbitMQを起動しましょう。Dockerを使って起動します。

docker run -d --name dapr_rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management

キューは5672番ポート、管理コンソールは15672番ポートで待ち受けるようにしました。

アプリケーションの修正

Dapr設定ファイルの作成

続いて、このRabbitMQに接続するための設定ファイルをpublishモジュール、subscribeモジュールのcomponentsディレクトリにそれぞれ作成します。

components/rabbitmq-pubsub.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: rabbitmq-pubsub
spec:
  type: pubsub.rabbitmq
  version: v1
  metadata:
  - name: host
    value: amqp://localhost:5672

pubsubname を分かりやすいよう rabbitmq-pubsub にしています。

host にはRabbitMQへの接続アドレスを指定します。

publish側のアプリケーション設定ファイルを追加

pusbsub名を変更したためPub/sub APIのパスも修正が必要です。URLの設定ファイルを追加で作成します。

(publish) application-rabbitmq.properties

pubsubUrl=http://localhost:${DAPR_HTTP_PORT}/v1.0/publish/rabbitmq-pubsub/my-message

メッセージトピックに my-message を指定しました。

subscribe側にDar設定ファイルの追加

さらに、rabbitmq-pubsub からデキューするようsubscribe側にSubscriptionの設定ファイルを作成します。

subscribe/components/subscription.yaml

apiVersion: dapr.io/v1alpha1
kind: Subscription
metadata:
  name: subscription
spec:
  pubsubname: rabbitmq-pubsub
  topic: my-message
  route: /subscribe
scopes:
  - subscribe-app

先に作成した設定ファイルから pubsubname の部分を rabbitmq-pubsub に変えただけです。

設定を有効にしてアプリケーションを起動

publish側の起動

それでは作成した設定ファイルが有効になるようDaprを使ってアプリケーションを起動します。まずはpublish側から起動します。

いったん停止させた後、次のコマンドで起動します。

dapr run --app-id publish-app --app-port 8083 --components-path ./components -- ../mvnw spring-boot:run -Dspring-boot.run.profiles=rabbitmq

components/rabbitmq-pubsub.yaml を有効にするために --components-path ./components を追加したのと、application-rabbitmq.properties が有効になるよう -Dspring-boot.run.profiles=rabbitmq を追加しました。

この時点でブラウザからRabbitMQの管理コンソールにアクセスしてみましょう。

http://localhost:15672/

初期アカウントは Username Password ともに guest です。

「Exchanges」タブでRabbitMQが管理しているExchangeの一覧を参照することができます。

f:id:cero-t:20211205082123j:plain
publish起動後のExchange一覧

この時点では、まだDaprに関するExchangeなどは特に作成されていませんね。

subscribe側の起動

続いて、subscribe側のアプリケーションを設定ファイルを有効にした状態で起動します。

dapr run --app-id subscribe-app --app-port 8084 --components-path ./components ../mvnw spring-boot:run

上と同じように --components-path ./components を追加しました。

もう一度、RabbitMQの管理コンソールにアクセスして、「Exchanges」タブを開いてみます。

f:id:cero-t:20211205082355j:plain
subscribe起動後のExchange一覧

Exchangeに my-message が作成されています。このExchangeをクリックして詳細を開いてみます。

f:id:cero-t:20211205082712j:plain
my-messageの詳細

このExchangeから subscribe-app-my-message というQueueにバインドされた状態になっています。RabbitMQに不慣れな方に説明すると、Exchangeはキューのルーティングを行うようなもので、送り先のキューを選択したり、複数のキューに同じメッセージを送ったりすることができます。Queueは文字通り単一のキューです。 AWSに詳しければ、ExchangeはSNS、QueueがSQSのような役割を果たすものだと考えてください。

メッセージを送る

起動を確認できたら、次のコマンドでメッセージを送ってみます。

curl -XPOST "localhost:8083/publish" -H "Content-type:application/json" -d '{
  "name": "Shin Tanimoto",
  "twitter": "@cero_t"
}'

subscribe側のコンソールに次のようなメッセージが表示されたはずです。

== APP == {"specversion":"1.0","source":"publish-app","type":"com.dapr.event.sent","topic":"my-message","id":"e9f066d7-d129-430e-b069-4bf01a50fb2d","datacontenttype":"application/json","pubsubname":"rabbitmq-pubsub","traceid":"00-ee177cd7c4aaaf83c591c04e34e2edb2-49e157665d77f0e3-01","data":{"name":"Shin Tanimoto","twitter":"@cero_t"}}

pubsubnamerabbitmq-pubsub に変わっていることが分かります。CloudEventsを無効にしているとこれらのパラメータが表示されないので、確認のため有効にしておくと良いでしょう。

ソースコードを変えることなく、設定を変えるだけで接続先をRedisからRabbitMQに変えることができました。

まとめ

  • Pub/sub APIを用いてメッセージブローカーに対してHTTPでメッセージを送ることができます
  • メッセージブローカーからメッセージを受け取る側は、Web APIと設定ファイルだけで作成ができます
  • つまりHTTPの送受信だけでメッセージングができます
  • 設定を変えるだけで別のメッセージブローカーに切り替えることができます

なお、長くなるので本文中では触れませんでしたが、publish側のURLを http://localhost:8084/subscribe に変更すれば、直接subscribe側のAPIを呼び出すこともできます。開発時にはDaprやメッセージブローカーを利用せずに同期処理としてビジネスロジックが正しく動くことを検証し、テスト時にはDaprやメッセージブローカーを持ちいて非同期通信で検証する、ということも可能になります。

特にキューを用いたアプリケーションをJUnitなどでテストする際には、非同期処理側が終わることを少し待ってからassertしなければなりませんが、テスト時には同期処理にしてしまえば、すぐに検証ができて楽ですよね。

今回はメッセージブローカーの独自APISDKを使わず、Daprを用いて通信をHTTP(gRPC)に寄せることで、設定だけで柔軟に環境を変えられるということが分かりました。僕がDaprを好きな理由の一つです。

それでは、また来週(明日)!

Dapr Advent Calendar 4日目 - Daprでデータストアにアクセス

こんにちは、Dapr Advent Calendar 4日目です。ここは "3日坊主の向こう側" です!

データストアの読み書きをしてみよう

今回はDaprを使ってデータストアへの読み書きを行います。

f:id:cero-t:20211203231211p:plain
今回作るアプリケーション

ソースコードgithubに置いてあります。

https://github.com/cero-t/dapr-advent-2021/tree/main/state

データを読み書きするアプリケーションの作成

まずはWebアプリケーションの作成です。DaprのState management APIを使い、データの書き込み処理と、読み込み処理をそれぞれ作成します。

StateController.java

@RestController
public class StateController {
    private RestTemplate restTemplate;

    @Value("http://localhost:${DAPR_HTTP_PORT}/v1.0/state/statestore")
    private String stateUrl;

    public StateController(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @PostMapping("/write")
    public void write(@RequestBody Object message) {
        restTemplate.postForObject(stateUrl, message, Void.class);
    }

    @GetMapping("/read/{key}")
    public Object read(@PathVariable String key) {
        return restTemplate.getForObject(stateUrl + "/" + key, Object.class);
    }
}

writeメソッドは書き込み処理、readメソッドは読み込み処理ですが、DaprのState management APIをそのまま呼び出しているだけで、他に何もしてません。APIの詳細は後で説明します。

このアプリケーションをポート番号8082で起動するよう、設定ファイルでポート番号を指定します。

application.properties

server.port=8082

これでアプリケーションの作成は完了です。

Daprを使ったアプリケーションの起動

このアプリケーションはDaprのState management APIを活用する前提で実装しているため、Daprを使って起動します。

dapr run --app-id state-app --app-port 8082 ../mvnw spring-boot:run

起動したら、まずは書き込み用のAPIJSONでメッセージを渡してデータの登録を行います。

curl -XPOST "localhost:8082/write" -H "Content-type:application/json" -d '[
  {
    "key": "cero_t",
    "value": {
      "name": "Shin Tanimoto",
      "twitter": "@cero_t"
    }
  },
  {
    "key": "BABYMETAL",
    "value": [
      {
        "name": "SU-METAL",
        "alias": "Suzuka Nakamoto"
      },
      {
        "name": "MOAMETAL",
        "alias": "Moa Kikuchi"
      },
      {
        "name": "YUIMETAL",
        "alias": "Yui Mizuno"
      }
    ]
  }
]'

key に登録するデータのキー、value に登録する内容を指定しています。これがState management APIの仕様で、後ほど詳しく説明します。

続いて、読み込み用のAPIを使ってデータの取得を行います。

curl localhost:8082/read/cero_t

次のようなデータが取得できるはずです。

{
  "name": "Shin Tanimoto",
  "twitter": "@cero_t"
}

もう一つのデータも取得してみましょう。

curl localhost:8082/read/BABYMETAL

次のようなデータが取得できます。

[
  {
    "alias": "Suzuka Nakamoto",
    "name": "SU-METAL"
  },
  {
    "name": "MOAMETAL",
    "alias": "Moa Kikuchi"
  },
  {
    "name": "YUIMETAL",
    "alias": "Yui Mizuno"
  }
]

問題なく取得できましたね。

この状態でアプリケーションを終了し、もう一度起動しても、問題なくデータは取得できます。データがきちんと保存されていることが分かりました。

f:id:cero-t:20211203231226p:plain
Daprを経由したアクセス

このデータはどこに保存されて、どこから取ってきたのでしょうか。その辺りを次の項で説明します。

やったことの解説

それではコードや打ったコマンドなどについて説明します。

State management APIと設定ファイル

今回はDaprのState management APIにそのままリクエストを流しただけですから、このAPIについて説明します。

State management APIのドキュメントはここにあります。

docs.dapr.io

DaprのState management APIのURLは http://localhost:${DAPR_HTTP_PORT}/v1.0/state/(データストア名) となります。

上で作ったソースコードで、このように記述していましたが

@Value("http://localhost:${DAPR_HTTP_PORT}/v1.0/state/statestore")

データストア名を statestore としています。もしこのストア名に異なる名前、たとえば my-store などを指定すると、次のようなエラーとなります。

state store my-store is not found

なぜ statestore と名前が決まっているのでしょうか。そして、これはどこのデータストアを指しているのでしょうか。

実はデータストアの設定は ~/.dapr/components/statestore.yaml に記載されています。

~/.dapr/components/statestore.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""
  - name: actorStateStore
    value: "true"

dapr run コマンドを実行してDaprを起動すると ~/.dapr/components にある設定ファイルが読まれます。今回はそこにある statestore.yaml が読み込まれたのです。

設定ファイルの仕様をよく知らなくても、なんとなくRedisを用いることと、localhost:6379を参照していることは分かると思います。また metadata.name に記載された statestore がデータストア名となります。

ここで指定されているRedisは、Dapr CLIdapr init した際に作成されたものです。docker ps コマンドでRedisが起動していることを確認してみましょう。

docker ps --filter name=redis

Redisのコンテナが見つかるはずです。

CONTAINER ID   IMAGE     COMMAND                  CREATED        STATUS        PORTS                    NAMES
de8b1f054509   redis     "docker-entrypoint.s…"   48 hours ago   Up 48 hours   0.0.0.0:6379->6379/tcp   dapr_redis

つまり、このRedisが6379番ポートで待ち受けており、それを使うよう ~/.dapr/components/statestore.yaml に記載されており、dapr run コマンド実行時にこのファイルが読み込まれて利用できるようになった、ということです。

ちなみに設定ファイルのパスは dapr run コマンド実行時の引数に --components-path を指定することで、任意のパスを指定することができます。それは後ほど試してみましょう。

データの登録と取得

データの登録と取得はState management APIを使いました。

登録のJSONは次のような形でした。

[
  {
    "key": "cero_t",
    "value": {
      "name": "Shin Tanimoto",
      "twitter": "@cero_t"
    }
  },
  {
    "key": "BABYMETAL",
    "value": [
      {
        "name": "SU-METAL",
        "alias": "Suzuka Nakamoto"
      },
      {
        "name": "MOAMETAL",
        "alias": "Moa Kikuchi"
      },
      {
        "name": "YUIMETAL",
        "alias": "Yui Mizuno"
      }
    ]
  }
]'

配列を使って複数のデータを登録できます。

それぞれのデータは keyvalue という項目が必須で、key が検索時にも使われるキーで、value がメッセージ本体となります。メッセージ本体は任意の形式のJSONを指定することができます。一般的なスキーマレスのKVSと同じですね。

取得する際にはState management APIのパスに key の値を渡せば1件ずつ取得できます。

{
  "name": "Shin Tanimoto",
  "twitter": "@cero_t"
}

他にも、複数のkeyを指定して取得するBulk APIや、削除するためのDelete API、またDapr v1.5ではフィルタリングをするためのQuery APIも追加されました(まだアルファ版です)

それぞれのAPIについてはAPIリファレンスを参考にしてください。

https://docs.dapr.io/reference/api/state_api/

異なるデータストアを使う

ここまでは、Daprが用意した設定ファイルとRedisを使ってデータの登録を行いましたが、他のデータストアを使うこともできます。

サポートされているデータストアの一覧はこちらに記載されています。

docs.dapr.io

ここではPostgreSQLを使ってみることにします。

PostgreSQLの起動

まずはPosgreSQLを起動しましょう。手っ取り早くDockerを使って起動します。

docker run -d --name dapr_postgres -p 5432:5432 -e POSTGRES_PASSWORD=secretpassword postgres

ローカルホストの5432番ポートで待ち受けるようにしました。

設定ファイルの作成

続いて、このPostgreSQLに接続するための設定ファイルをcomponentsディレクトリに作成します。

state/components/posgresql-store.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.postgresql
  version: v1
  metadata:
  - name: connectionString
    value: host=localhost user=postgres password=secretpassword port=5432 connect_timeout=10 database=postgres

上の方で作ったソースコード内で指定した statestore の名前を変えなくて良いよう、ここでも metadata.name には statestore を使用しました。

そしてこの設定ファイルを使って起動するようDaprのコマンド引数に --components-path ./components を追加します。

dapr run --app-id state-app --app-port 8082 --components-path ./components ../mvnw spring-boot:run

この時点で一度、PostgreSQLのデータがどうなっているか見に行ってみましょう。

起動中のdockerコンテナに入って

docker exec -it $(docker ps -q --filter name=dapr_postgres) /bin/bash

psqlコマンドでアクセスします。

psql -U postgres

テーブル一覧を参照します。

\d

結果がこうなるはずです。

         List of relations
 Schema | Name  | Type  |  Owner   
--------+-------+-------+----------
 public | state | table | postgres

Daprが起動した時点で state という名前のテーブルが作成されていました。

いちおうデータを参照してみると

select * from state;

もちろん空っぽです。

 key | value | isbinary | insertdate | updatedate 
-----+-------+----------+------------+------------
(0 rows)

それでは別のコンソールでcurlコマンドを打って、データを登録してみましょう。

curl -XPOST "localhost:8082/write" -H "Content-type:application/json" -d '[
  {
    "key": "cero_t",
    "value": {
      "name": "Shin Tanimoto",
      "twitter": "@cero_t"
    }
  },
  {
    "key": "BABYMETAL",
    "value": [
      {
        "name": "SU-METAL",
        "alias": "Suzuka Nakamoto"
      },
      {
        "name": "MOAMETAL",
        "alias": "Moa Kikuchi"
      },
      {
        "name": "YUIMETAL",
        "alias": "Yui Mizuno"
      }
    ]
  }
]'

その後、もう一度PostgreSQLのコンテナ内でデータを参照してみます。

select * from state;

今度はきちんとデータが登録されていました。

         key          |                                                                     value                                                                     | isbinary |          
insertdate           | updatedate 
----------------------+-----------------------------------------------------------------------------------------------------------------------------------------------+----------+----------
---------------------+------------
 state-app||cero_t    | {"name": "Shin Tanimoto", "twitter": "@cero_t"}                                                                                               | f        | 2021-11-3
0 22:05:34.759534+00 | 
 state-app||BABYMETAL | [{"name": "SU-METAL", "alias": "Suzuka Nakamoto"}, {"name": "MOAMETAL", "alias": "Moa Kikuchi"}, {"name": "YUIMETAL", "alias": "Yui Mizuno"}] | f        | 2021-11-3
0 22:05:34.760984+00 | 
(2 rows)

keyの部分だけ見てみると

         key          
----------------------
 state-app||cero_t
 state-app||BABYMETAL

(app-id)||key となっていることが分かり、app-idごとにデータが永続化されているということが分かりましたね。

え、なぜRedisではきちんと調べなかったのに、PostgreSQLではこんなにきちんと調べるか、ですって?

そりゃ僕がRedisを使ったことがなくて、コマンドを全然知らないからですよ。ガハハハハ。

まとめ

  • State management APIを用いてデータの登録や取得ができます
  • ローカル環境では ~/.dapr/components にある設定ファイルが読み込まれます
  • Daprの初期化時に作成したRedisが利用されるように設定されています
  • 設定ファイルの場所は dapr run コマンドの --components-path で指定できます
  • PostgreSQLをはじめ、いくつかのRDBMSやNoSQLなどを同じAPIで利用できます

最後の最後でこういうことを言うのもアレなのですが、僕はこのState management APIを仕事で使ったこともなければ、使おうと思ったことすらありません。正直なところ、シンプルなCRUDで済むアプリケーションならまだしも、少しでも複雑な業務アプリケーションではこのAPIでは機能が全く足りないため、あまり使うメリットがないと判断しています。

ちょうどよいユースケースがあれば良いのですけどね。

それでは、また明日!

Dapr Advent Calendar 3日目 - Daprでサービスを呼ぶ

こんにちは、Dapr Advent Calendar 3日目です。ついに3日坊主達成です!

別のサービスを呼んでみよう

前回のエントリー ではDaprを使ってHello Worldアプリケーションを呼び出しましたが、今回はひとつのWebアプリケーションから、Dapr経由で別のWebアプリケーションを呼び出してみます。

f:id:cero-t:20211203033040p:plain
今回作るアプリケーション

ソースコードgithubに置いてあります。

https://github.com/cero-t/dapr-advent-2021

このソースコードの例ではMavenのマルチモジュール構成で、次のように作ってあります。

  • dapr-advent-2021
    • mvnw
    • pom.xml
    • hello (前回説明したもの)
    • invoke (今回新たに作るもの)

Daprを使わないWebアプリケーションの作成

まずはDaprを使わずに、呼び出される側と呼び出す側のWebアプリケーションをそれぞれ作成します。

呼び出される側のWebアプリケーションは、前回のHello World(helloモジュール)をそのまま使います。

(hello) HelloController.java

@RestController
public class HelloController {
    @GetMapping("/hello")
    public Map<String, String> hello() {
        return Map.of("message", "Hello, world!");
    }
}

そして新たに別のWebアプリケーション(invokeモジュール)を作成します。上で作ったHello Worldのアプリケーションを呼び出すためのアプリケーションです。

(invoke) InvokeController.java

@RestController
public class InvokeController {
    private RestTemplate restTemplate;

    public InvokeController(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @GetMapping("/invokeHello")
    public Map<String, ?> invokeHello() {
        Map<?, ?> result = restTemplate.getForObject("http://localhost:8080/hello", Map.class);
        return Map.of("remoteMessage", result);
    }
}

helloアプリケーションの /hello を呼び出すだけの簡単な処理です。

RestTemplateを使うため、ApplicationクラスでBean定義をしておきます。

(invoke) InvokeApplication.java

@SpringBootApplication
public class InvokeApplication {
    public static void main(String[] args) {
        SpringApplication.run(InvokeApplication.class, args);
    }

    @Bean
    RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder.build();
    }
}

Spring Bootの使い方みたいなところの説明は割愛しますね。

また、このアプリケーションをポート番号8081で起動するよう、設定ファイルでポート番号を指定します。

(invoke) application.properties

server.port=8081

これでアプリケーションは完成です。

まずはそれぞれのWebアプリケーションを別々のコンソールで起動します。

cd hello
../mvnw spring-boot:run
cd invoke
../mvnw spring-boot:run

アプリケーションが起動したらcurlコマンドでアクセスします。

curl localhost:8081/invokeHello

無事に実行結果が表示されました。

{"remoteMessage":{"message":"Hello, world!"}}

結果を見やすいようjqコマンドなどで整形するとこんな感じです(以降も基本的にjqで整形した結果を表示します)

{
  "remoteMessage": {
    "message": "Hello, world!"
  }
}

これで別サービスの呼び出しができました。

f:id:cero-t:20211203034815p:plain
ここまでで作ったアプリケーション

ただ、ソースコード内に相手側のアドレスやポート番号を書かなくてはならないという問題がありますね。これをDaprで解決しましょう。

URLを設定ファイルに移動させる

Daprを使ってアプリケーション起動する前に、ソースコードのURLの一部を設定ファイルに移動させます。

(invoke) InvokeController.java(抜粋)

@Value("${baseUrl}")
private String baseUrl;

@GetMapping("/invokeHello")
public Map<String, ?> invokeHello() {
    Map<?, ?> result = restTemplate.getForObject(baseUrl + "/hello", Map.class);
    return Map.of("baseUrl", baseUrl, "remoteMessage", result);
}

URL部分をbaseUrlの値で動的に変えられるようにしました。また、利用したbaseUrlの値を確認するために、レスポンスにbaseUrlを入れるようにしました。

baseUrlは application.properties で定義します。

(invoke) application.properties

server.port=8081
baseUrl=http://localhost:8080

これでURLの一部を設定ファイルに移動させることができました。このURLをDapr向けに書き換えた設定ファイルも別に作っておきます。

(invoke) application-dapr.properties

baseUrl=http://localhost:${DAPR_HTTP_PORT}/v1.0/invoke/hello-app/method

DAPR_HTTP_PORT は、Daprのポート番号を示しています。Dapr経由でアプリケーションを起動すると、この環境変数にDapr自身のポート番号が入ります。アプリケーションはこのポートを経由して、自身のサイドカーであるDaprプロセスのAPIを利用できるようになるのです。このポート番号を使い、前回説明したInvoke APIを使ってアプリケーションにアクセスするように記述しました。

念のため、この時点でアプリケーションの動作確認をしておきましょう。invokeアプリケーションをいったん停止してから再起動します。

../mvnw spring-boot:run

curlコマンドでアクセスします。

curl localhost:8081/invokeHello 

実行結果にbaseUrlが入るようになりました。

{
  "baseUrl": "http://localhost:8080",
  "remoteMessage": {
    "message": "Hello, world!"
  }
}

これで準備が整いました。次はDaprを使ってアプリケーションを起動してみましょう。

Daprとともにアプリケーションを起動

まずはDaprを使ってhelloアプリケーションを起動します。

helloアプリケーションを停止させた後、次のコマンドで起動します。

dapr run --app-id hello-app --app-port 8080 --dapr-http-port 18080 ../mvnw spring-boot:run

続いて、invokeアプリケーションも同じように停止させたあと、次のコマンドで起動します。

dapr run --app-id invoke-app --app-port 8081 --dapr-http-port 18081 -- ../mvnw spring-boot:run -Dspring-boot.run.profiles=dapr

mvnwコマンドに -Dspring-boot.run.profiles=dapr という引数を追加して、application-dapr.properties の設定が有効になるようにしました。また引数を追加するためmvnwコマンドの前に -- を追加しました。

それではDapr経由でWebアプリケーションにアクセスしてみましょう。curlコマンドでアクセスします。

curl localhost:18081/v1.0/invoke/invoke-app/method/invokeHello

次のような結果が表示されるはずです。

{
  "remoteMessage": {
    "message": "Hello, world!"
  },
  "baseUrl": "http://localhost:18081/v1.0/invoke/hello-app/method"
}

ふつうにアクセスした時と同じメッセージが取得でき、baseUrlはDaprのAPI形式になりました。

なおinvokeアプリケーションは、別にDaprを経由せずに呼び出しても構いません。

curl localhost:8081/invokeHello

上と同じ実行結果が得られます。

{
  "remoteMessage": {
    "message": "Hello, world!"
  },
  "baseUrl": "http://localhost:18081/v1.0/invoke/hello-app/method"
}

これで、Dapr経由で2つのWebアプリケーションが連携できました。

あくまでも自身のサイドカーであるDaprと通信するだけで、別のDapr経由で起動しているアプリケーションに対してアクセスができた、ということです。

f:id:cero-t:20211203035529p:plain
Daprを経由したアクセス

やったことの解説

今回は2つのアプリケーションを起動して、Dapr経由でアクセスすることができました。Daprがちょうどサービスディスカバリーやルーティングのような機能を有していると言えます。Spring CloudでいうEurekaやDiscovery Clientに相当する機能です。

この機能に関するドキュメントはこちらになります。

docs.dapr.io

f:id:cero-t:20211203034219p:plain

Dapr同士が協調して、Invoke APIに含まれるapp-id(今回は hello-app)を名前解決して相手のアプリケーションを発見し、その相手のアプリケーションのサイドカーを経由してアプリケーションにアクセスする形となっています。

この名前解決には、ローカル環境ではマルチキャストDNSが利用され、k8s上ではk8sの名前解決が利用されるほか、Consulを利用することもできます。

この名前解決の機能を利用すれば、たとえばこのようなアクセスも可能です。

curl http://localhost:18080/v1.0/invoke/hello-app/method/hello
curl http://localhost:18081/v1.0/invoke/hello-app/method/hello

Daprのポート番号 1808018081 で異なっていますが、どちらのURLでアクセスしても、同じ結果が返ってきます。

{
  "message": "Hello, world!"
}

どのDaprにアクセスしても hello-app という名前からアクセスすべきアプリケーションを探してアクセスを行うのです。Daprを使う時には「どのDaprにアクセスするか」はあまり関係なく、Daprネットワークにアクセスしている、という感覚が近いと言えるでしょう。

環境への依存を下げる

今回説明したサービス呼び出しに関連して、もう少し環境周りのパラメータを減らす方法を説明しておきます。

Daprのポート番号を指定せずに起動する

Daprの理解を深めるために、Daprのポート番号を指定せずにアプリケーションを起動するようにします。

helloアプリケーションを一度停止し、次のコマンドで起動します。--dapr-http-port を指定しない形です。

dapr run --app-id hello-app --app-port 8080 ../mvnw spring-boot:run

invokeアプリケーションも一度停止して、同じように --dapr-http-port を指定せずに起動します。

dapr run --app-id remote-call-app --app-port 8081 -- ../mvnw spring-boot:run -Dspring-boot.run.profiles=dapr

そしてcurlコマンドで直接invokeアプリケーションにアクセスします。

curl localhost:8081/invoke

これでも正常に実行結果が得られました。

{
  "remoteMessage": {
    "message": "Hello, world!"
  },
  "baseUrl": "http://localhost:65275/v1.0/invoke/hello-app/method"
}

DaprのHTTPポート番号がランダムに決まったたとしても、環境変数 DAPR_HTTP_PORT を使っているため自分のサイドカーとして起動しているDaprにアクセスできます。空いているポート番号を適当に使っても構わなくなるのは楽で良いですよね。

Daprの設定ファイルを使わずに起動する

ここまでの例では application-dapr.properties を使っていましたが、これはアプリケーション側がDaprに依存している形にも見えます。このファイルを使わずに、コマンド引数で接続先を指定する形も試してみましょう。

次のコマンドで起動します。

dapr run --app-id remote-call-app --app-port 8081 -- ../mvnw spring-boot:run -Dspring-boot.run.arguments='--baseUrl=http://localhost:${DAPR_HTTP_PORT}/v1.0/invoke/hello-app/method'

baseUrl をコマンド引数として渡すようにしました。このコマンド実行時点で ${DAPR_HTTP_PORT} が解釈されてしまわないよう、引数はシングルクォートで囲っています。

これで起動したアプリケーションにcurlコマンドでアクセスすると

curl localhost:8081/invoke

正常に実行結果が得られました。

{
  "remoteMessage": {
    "message": "Hello, world!"
  },
  "baseUrl": "http://localhost:51939/v1.0/invoke/hello-app/method"
}

このようにすればアプリケーション側のソースコードや設定ファイルからDaprに対する依存を避けることができます。Spring Bootが環境変数に応じて処理を切り替えやすい構造になっているおかげとも言えます。

もちろんコマンドライン引数でわざわざこんな長いURLを指定するのは面倒ですが、たとえばk8sのマニュフェストファイルで環境変数に指定する、というような状況であればこの形で指定することも妥当なやり方の一つになると思います。

まとめ

それでは今回の内容を簡単に振り返りましょう。

  • Dapr経由でアプリケーションを複数起動すると、お互いに相手の名前を指定して通信することができます
  • ローカル環境ではマルチキャストを使ってDapr同士が連携し、他のDaprを探しに行きます
  • アプリケーションは DAPR_HTTP_PORT という環境変数で自分のサイドカーであるDaprのポート番号を認識します
  • 環境変数で処理を切り替えやすい構造にしていれば、より環境への依存を下げることができます

正直、僕はこの機能を見ただけでもDaprを使ってみようという気持ちになりました。他のサービスディスカバリーのサーバなしで名前解決できるのって、何気に嬉しいですよね。

それでは!