かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

TypeScript を使ってサーバーレスで WebSocket サーバーを作ってみた

先日 Microsoft Azure のサーバーレスプラットフォームの Azure Functions に SignalR Service bindings の一般提供開始のアナウンスがありました!

azure.microsoft.com

リポジトリはこちら。

github.com

ということで、これもまた先日 Azure Functions Core Tools でサポートされた TypeScript のプロジェクトテンプレートを使ってやってみたいと思います。

やる前に

Azure Functions の SignalR Service bindings にはクライアントに接続のための情報を返すための機能と、繋いでいるクライアントへメッセージを送信するための出力バインディングがあります。

つまり、Azure Functions 側から繋ぎに来ているクライアントにメッセージをブロードキャストすることは出来ますが、クライアントからの SignalR での通信を受けることは出来ないっぽいようです。 クライアントから Azure Functions へは HttpTrigger あたりを使った普通の REST API 呼び出しを使う感じですね。

プロジェクト作成

Azure Functions の拡張機能を入れた Visual Studio Code をベースに作業をしていきたいと思います。 ということでさくっと Create New Project... で Azure Functions のプロジェクトを作成します。

f:id:okazuki:20190309111307p:plain

言語は TypeScript を選ぼう

f:id:okazuki:20190309111249p:plain

そして以下のコマンドを実行して SignalR 用の拡張をインストールします。

func extensions install --package Microsoft.Azure.WebJobs.Extensions.SignalRService --version 1.0.0

SignalR 対応をしていこう

SignalR 接続情報入力バインドというものを作ってクライアントに Azure の SignalR Service に接続するための情報を返してあげます。とりあえず HttpTrigger の関数を作って SignalR の接続情報を返す処理を追加します。名前は negotiate じゃないとダメみたいです。

func new コマンドか Visual Studio Code の関数を作成するボタン(プロジェクトを作った時に押したボタンの横にある)で HttpTrigger の関数を作ります。

作成した関数の function.jsonsignalRConnctionInfo のバインドを追加します。

{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": [
        "get",
        "post"
      ]
    },
    {
      "type": "signalRConnectionInfo",
      "name": "connectionInfo",
      "hubName": "chat",
      "connectionStringSetting": "SignalRConnection",
      "direction": "in"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "..\\dist\\negotiate\\index.js"
}

関数の中では入力として受け取った接続情報を単純に HTTP のボディにセットして返します。

import { AzureFunction, Context, HttpRequest } from "@azure/functions"

const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest, connectionInfo: any): Promise<void> {
    context.res = { body: connectionInfo };
};

export default httpTrigger;

これでクライアントから繋ぐことができるようになったので、続けて繋いできたクライアントにメッセージを投げようと思います。それ用の関数として PostMessage という名前の HttpTrigger 関数を作成します。

接続してきているクライアントにメッセージを投げるには SignalR 用の出力バインディングを関数に追加してやれば良さそう。 ということで PostMessage 関数の function.json に signalr の出力バインディングの定義を追加します。

{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": [
        "get",
        "post"
      ]
    },
    {
      "type": "signalR",
      "name": "signalRMessages",
      "hubName": "chat",
      "connectionStringSetting": "SignalRConnection",
      "direction": "out"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "..\\dist\\PostMessage\\index.js"
}

出力バインディングに渡す C# のオブジェクトを見てみると userId, groupName, target, arguments を渡してやればよさそう。 targetarugments が必須での折はオプションみたいです。ということで、このようなインターフェースを TypeScript で定義しました。

export interface SignalRMessage {
    userId?: string
    groupName?: string
    target: string
    arguments: {[key:string]: any}
}

そして PostMessage 関数で適当にリクエストの body をそのままクライアントに渡すように作成しました。

import { AzureFunction, Context, HttpRequest } from "@azure/functions"
import { SignalRMessage } from "../signalr/signalrmessage"

const httpTrigger: AzureFunction = async function (context: Context, 
    req: HttpRequest): Promise<void> {
    context.log(JSON.stringify(req.body));
    context.bindings.signalRMessages = new Array<SignalRMessage>({
        target: "receiveMessage",
        arguments: [
            req.body,
        ],
    });
};

export default httpTrigger;

テスト

ここまで出来たらテストしてみます。Azure に SignalR Service を作成しましょう。 大量のデータはさばけないけど開発用には Free プランで作っておけばよさそうです。エミュレーターとかあればもうちょっと捗りそうだけど、まぁ仕方ないのかな。

作成した SignalR Service の Keys を見ると接続文字列があるのでコピーします。

f:id:okazuki:20190309134201p:plain

ローカル用のアプリケーション設定を書く local.settings.json を開いて function.json で使ってた SignalRConnection という名前で先ほどの接続文字列を追加します。

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "node",
    "SignalRConnection": "ここに接続文字列を張り付け"
  }
}

テスト用クライアントを作成します。クライアントは何でもいいのですが、今回は Vue.js で試してみようと思います。因みに現時点では JavaScript, Java, C# あたりに対応しています。Swift とかはプレビューみたいです。

docs.microsoft.com

vue の cli を入れてる状態で vue create vue-signalr-client といった感じで任意の場所に TypeScript のプロジェクトを作ります。 プロジェクトを作ったら npm i @aspnet/signalr で SignalR を追加します。

src/components/HelloWorld.vue をいじっていきます。

<template>
  <div>
    <div>
      <input type="text" v-model="message" />
      <button @click="onPostMessageClick">PostMessage</button>
    </div>
    <div>
      <div :key="index" v-for="(chatMessage, index) in chatMessages">
        {{ chatMessage }}
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { HubConnection, HubConnectionBuilder, LogLevel, JsonHubProtocol } from '@aspnet/signalr';

@Component
export default class HelloWorld extends Vue {
  public message: string = '';
  public chatMessages: string[] = [];

  private connection!: HubConnection;

  public async onPostMessageClick(): Promise<void> {
    if (this.connection == null) {
      return;
    }

    await fetch('http://localhost:7071/api/PostMessage', {
      method: 'POST',
      body: JSON.stringify({ text: this.message }),
    });

    this.message = '';
  }

  public async created(): Promise<void> {
    console.log('created called');
    this.connection = new HubConnectionBuilder()
      .withUrl('http://localhost:7071/api')
      .configureLogging(LogLevel.Information)
      .build();
    this.connection.on('receiveMessage', (message) => {
      console.log(JSON.stringify(message));
      this.chatMessages.push(message.text);
    });
    await this.connection.start();
    console.log('created finished: ' + this.connection);
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

created 関数で SignalR の接続を作成しています。HubConnectionBuilderwithUrl メソッドで Azure Functions の URL の api の部分までを渡します。そうすると勝手に negotiate 関数を探しに行って接続を確立してくれます。

本番では、これだけじゃなくて再接続処理も入れないといけないっぽいですね。

ASP.NET Core SignalR JavaScript クライアント | Microsoft Docs

CORS の設定

さて、Vue のプロジェクトを実行すると http://localhost:8080 で起動するので Azure Functions のローカルのランタイムに CORS の設定をしてやります。 local.settings.json を開いて以下のように CORS の設定を追加します。

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "node",
    "SignalRConnection": "Endpoint=https://okazukisignalr.service.signalr.net;AccessKey=bH4HAffm8395oxBUyA2D3kBPYSOyhv0it1B5teCXkdA=;Version=1.0;"
  },
  "Host": {
    "CORS": "http://localhost:8080",
    "CORSCredentials": true
  }
}

動かす

Azure Functions の方のプロジェクトを実行して、Vue のクライアント側を実行します。2つのブラウザーで Vue のクライアントのページを開いて動かしてみると…

動いた!

f:id:okazuki:20190309154432p:plain

クラウドにデプロイ

では Azure SignalR Service を作成したところに Function App を従量課金プランで作成します。 ランタイムスタックは今回の場合は JavaScript ですね。

Function App が作成されたら、アプリケーション設定を開いて SignalRConnection というキーで SignalR Service への接続文字列を追加しておきます。

追加したら func azure functionapp publish FunctionAppの名前 と打ち込んでデプロイしましょう。

テスト用の Vue のアプリもデプロイしましょう。デプロイ先は Azure のほうに Storage Account を作って静的 Web サイトのオプションをオンにします。インデックスドキュメントは index.html にしておきましょう。

f:id:okazuki:20190309160039p:plain

HelloWorld.vue で SignalR の接続先が http://localhost:8080 となっている部分を https://FunctionApp名.azurewebsites.net に変えます。そして、Vue のプロジェクトで npm run build してプロダクション用にビルドします。 dist フォルダーの中身を先ほど作ったストレージアカウントの BLOB の $web というコンテナにアップロードします。 Azure Storage Explorer 使うと楽です。

そして Function App の CORS の設定でストレージアカウントのプライマリエンドポイントの名前を設定します。

f:id:okazuki:20190309160817p:plain

ここまで完了すると以下のように Azure 上でも動くはずです。

f:id:okazuki:20190309160946p:plain

おまけ

.NET クライアントライブラリや Java クライアントライブラリもあるので…

docs.microsoft.com

docs.microsoft.com

こんな感じで Web ページと C#, Java あたりで作ったアプリで同じデータを受信するようにもできます。

f:id:okazuki:20190309174018p:plain

参考

docs.microsoft.com

ソースコード

GitHub にあげてます。因みに Azure のリソースは全て削除してあるので動かすときはソースコード内にハードコーディングされてる URL は書き換えてください。

github.com

最後に

今回は気分で TypeScript を使いましたが C#, Java とかでもサーバーサイドは書けます。

Durable Functions あたりと組み合わせるとオーケストレーター関数の進捗状況のプッシュ通知的なものに SignalR Service 使って通知とかできそうで面白そうですね。