LineSimulatorを利用してDurable FunctionsでロングランなLineBotをローカル環境交えて作ってみる

スポンサーリンク

この記事はMicrosoft Azure Advent Calendar 2017 の11日目の記事です。

LineSimulatorとDurable Functionsを利用してロングランできるLineBotをローカルでお手軽に作れないかなと思って試した内容を書いていきます。

ひとえにLineBotを作るといってもDurable FunctionsでのAsyncHttpAPIsパターンはDurableTaskに隠蔽されていてLineAPIの形式でデータのやり取りをすることはできません。なので今回はLineSimulatorとDurable Functionsの間にIF用のFunctionを用意してデータの変換や通信のなかどりを受け持つようにします。Durable FunctionsはAzure側に立てておいて、IFとなるFunctionとLineSimulatorをローカル環境で作成することとなります。

絵に起こしてみるとこんな感じです。

今回の実施環境前提は色々あるのですが、いちいち書いていると長くなりすぎるので割愛してます。環境構築に興味のある方は下記の情報を参考にしてみてください。

Azure Durable Functionsのインストール

docs.microsoft.com

LineSimulatorの環境構築(本家)

blogs.msdn.microsoft.com

LineSimulatorの環境構築

404 NOT FOUND | kokoni
おっさんの日常とクラウドと

LineSimulatorのローカル実行方法

404 NOT FOUND | kokoni
おっさんの日常とクラウドと

LineDevelopers

developers.line.me

※本記事は2017年12月11日時点の情報となります

試した内容

今回はDurableFunctionsのFunctionChainパターンをAzure側に立てておいてローカル環境でLineSimulatorとIF用のFuncitonを構築して試していきます。

まずはAzure側にDurableFunctionsを構築します。とりあえず下記を参考にAzureにFunctionsを作成しましょう。

docs.microsoft.com

Functionsを作成したら次はFunctionsnAppの設定からランタイムバージョンをbetaにします。betaにするとDurableFunctionsに対応します。

次にDurableTaskCoreとDurableFunctionsのサンプルソースをインストールします。

ここから一式をダウンロードしてください。

ダウンロードしたらFunctionsのダッシュボードからkuduを起動します。

kuduのDebugConsoleからCMDを選択してsiteからwwwrootに移動すると上記で作成したFunctionが作成されていると思います。上記でダウンロードしたファイルを下記の動画の要領で配置してください。

上記の作業後Functionに戻るとサンプルソースに従ったFunctionsが作成されていると思います。今回使うのはE1_HelloSequenceとE1_SayHelloです。

注意:先週実施されたAzure Function Portalの更改でポータルからDurableTaskCoreのインストールが出来なくなっているようです。もしDurableFunctionの環境構築を行う場合はVisualStudioでプロジェクトを作成して各種Nugetを行った後にAzureにDeployする必要があるみたいです。ここで紹介している方法は古い方法になっている可能性がありますので新しいやり方については別途記事に起こす方針です。環境構築を参考にされている方は注意していただければ幸いです。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
using Newtonsoft.Json;

namespace LineBot
{
	public static class Function1
	{
		[FunctionName("Function1")]
		public static async Task<HttpResponseMessage> Run([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)]HttpRequestMessage req, TraceWriter log)
		{
			log.Info("Start");
			// リクエストJSONをパース
			var jsonContent = await req.Content.ReadAsStringAsync();
			var data = JsonConvert.DeserializeObject<Request>(jsonContent);
			string replyToken = null;
			string messageType = null;
			string message = null;
			
			// 本来はLineDeveloperCenterの値を設定
			string ChannelAccessToken = "dummyToken";
			
			// リクエストデータからデータを取得
			foreach (var item in data.Events)
			{
				// リプライデータ送付時の認証トークンを取得
				replyToken = item.ReplyToken;
				if (item.Message == null) continue;
				messageType = item.Message.type;
				message = item.Message.text;
			}
			string reqData = string.Empty;
			if (message.Contains("StatusQueryGetUri"))
			{
				var content = await GetStatusQueryAsync(message.Split(',').Last());
				StatusQueryResponse StatusQueryJson = null;
				if (content != null)
				{
					StatusQueryJson = JsonConvert.DeserializeObject<StatusQueryResponse>(content);
				}
				var replyMessage = ReplyMessages(replyToken, StatusQueryJson);
				reqData = JsonConvert.SerializeObject(replyMessage);
			}
			else
			{
				var DurableResponse = await RequestDurableFunctionsAsync();
				var DurableContent = RichButtonTemplate(DurableResponse);
				reqData = JsonConvert.SerializeObject(DurableContent);
			}
			
			// レスポンスの作成
			using (var client = new HttpClient())
			{
				// リクエストデータを作成
				HttpRequestMessage request =
				new HttpRequestMessage(HttpMethod.Post, "http://localhost:8080/v2/bot/message/reply")
				{
					Content = new StringContent(reqData, Encoding.UTF8, "application/json")
				};
				
				// 認証ヘッダーを追加
				client.DefaultRequestHeaders.Add("Authorization", $"Bearer {ChannelAccessToken}");
				
				// 非同期でPOST
				var res = await client.SendAsync(request);
				return req.CreateResponse(res.StatusCode);
			}
		}
		
		/// <summary>
		/// DurableFunctionsにリクエスト
		/// </summary>
		private static async Task<DurableAsyncHTTPAPIs> RequestDurableFunctionsAsync()
		{
			string eventName = string.Empty;
			string text = "キャンセル理由";
			
			// レスポンスの作成
			using (var client = new HttpClient())
			{
				// リクエストデータを作成
				HttpRequestMessage request =
				new HttpRequestMessage(HttpMethod.Post, "https://{独自に作成したドメインに書き直してください}/orchestrators/{独自に作成したFunction名に書き直してください}");
				
				// 非同期でPOST
				var res = await client.SendAsync(request);
				
				// レスポンスデータからHttpAsyncAPI用のURLを作成
				DurableAsyncHTTPAPIs  req = new DurableAsyncHTTPAPIs()
				{
					Id = "",
					StatusQueryGetUri = $"{res.Headers.Location.Scheme}://{res.Headers.Location.Host}/{res.Headers.Location.LocalPath}?{res.Headers.Location.Query}",
					SendEventPostUri = $"{res.Headers.Location.Scheme}://{res.Headers.Location.Host}/{res.Headers.Location.LocalPath}/raiseEvent/{eventName}?{res.Headers.Location.Query}",
					TerminatePostUri = $"{res.Headers.Location.Scheme}://{res.Headers.Location.Host}/{res.Headers.Location.LocalPath}/terminate?reason={text}&{res.Headers.Location.Query}"
				};
				return req;
			}
		}
		
		/// <summary>
		/// 処理状況を取得するHTTPAsyncAPI
		/// </summary>
		private static async Task<string> GetStatusQueryAsync(string uri)
		{
			// レスポンスの作成
			using (var client = new HttpClient())
			{
				// 非同期でPOST
				var response = await client.GetAsync(uri);
				return await response.Content.ReadAsStringAsync();
			}
		}
		
		/// <summary>
		/// ボタンテンプレートでリプライ情報の作成
		/// </summary>
		private static ResponseDuralbe RichButtonTemplate(DurableAsyncHTTPAPIs message)
		{
			var res = new ResponseDuralbe();
			var lineButtonMessage = new LineButtonMessage();
			var lineButton = new LineButtonMessageTemplate();
			var action = new LineButtonAction();
			res.replyToken = "dummyToken";
			res.messages = new List<LineButtonMessage>()
			{
				new LineButtonMessage()
				{
					altText = "処理を開始しました",
					template = new LineButtonMessageTemplate()
					{
						title = "Durable Functions Test",
						text = "処理開始!!",
						actions = new List<LineButtonAction>()
						{
							new LineButtonAction()
							{
								label="処理確認",
								data = String.Format("StatusQueryGetUri,{0}",message.StatusQueryGetUri)
							}
						}
					}
				}
			};
			return res;
		}
		
		/// <summary>
		/// リプライメッセージの作成
		/// </summary>
		static Response ReplyMessages(string token, StatusQueryResponse contents)
		{
			Messages msg = new Messages();
			StringBuilder sb = new StringBuilder();
			if (contents==null)
			{
				sb.Append("未処理");
			}
			else
			{
				string StartTime = $"処理開始時間:{contents.createdTime}";
				if (contents.runtimeStatus == "Running")
				{
					sb.Append("処理中");
					sb.Append(Environment.NewLine);
					sb.Append(StartTime);
				}
				else if (contents.runtimeStatus == "Completed")
				{
					sb.Append("処理完了");
					sb.Append(Environment.NewLine);
					sb.Append(StartTime);
					sb.Append(Environment.NewLine);
					foreach (var item in contents.output)
					{
						sb.Append(item);
						sb.Append(Environment.NewLine);
					}
				}
				else if (contents.runtimeStatus == "Failed")
				{
					sb.Append("処理失敗");
					sb.Append(Environment.NewLine);
					sb.Append(StartTime);
				}
				else
				{
					sb.Append("不明");
					sb.Append(Environment.NewLine);
					sb.Append(StartTime);
				}
			}
			
			// リプライメッセージの作成
			var res = new Response()
			{
				replyToken = token,
				messages = new List<Messages>()
				{
					new Messages()
					{
						type ="text",
						text = sb.ToString().Replace(Environment.NewLine,"<br>")
					}
				}
			};
			return res;
		}
		
		//********************************************
		// Lineからのリクエストメッセージ用クラス
		//********************************************
		// リクエスト
		public class Request
		{
			public List<Event> Events { get; set; }
		}
		
		// イベント
		public class Event
		{
			public string ReplyToken { get; set; }
			public string Type { get; set; }
			public object Timestamp { get; set; }
			public Source Source { get; set; }
			public Message Message { get; set; }
		}
		
		// ソース
		public class Source
		{
			public string type { get; set; }
			public string userId { get; set; }
		}
		
		// リクエストメッセージ
		public class Message
		{
			public string id { get; set; }
			public string type { get; set; }
			public string text { get; set; }
		}

		//********************************************
		// Lineへのリプライメッセージ用クラス
		//********************************************
		// レスポンス
		public class Response
		{
			public string replyToken { get; set; }
			public List<Messages> messages { get; set; }
		}
		
		// レスポンスメッセージ
		public class Messages
		{
			public string type { get; set; }
			public string text { get; set; }
		}

		//********************************************
		// Lineへのボタンテンプレート用クラス
		//********************************************
		public class ResponseDuralbe
		{
			public string replyToken { get; set; }
			public List<LineButtonMessage> messages { get; set; }
		}
		
		// Line ボタン
		public class LineButtonMessage
		{
			public string type { get;  } = "template";
			public string altText { get; set; }
			public LineButtonMessageTemplate template { get; set; }
		}
		
		// Line ボタンテンプレート
		public class LineButtonMessageTemplate
		{
			public string type { get;  } = "buttons";
			public string thumbnailImageUrl { get; set; } = string.Empty;
			public string imageAspectRatio { get; set; } = string.Empty;
			public string imageSize { get; set; } = string.Empty;
			public string imageBackgroundColor { get; set; } = string.Empty;
			public string title { get; set; } = string.Empty;
			public string text { get; set; } = string.Empty;
			public List<LineButtonAction> actions { get; set; }
		}
		
		// Action ボタン
		public class LineButtonAction
		{
			public string type { get;  } = "postback";
			public string label { get; set; } = string.Empty;
			public string data { get; set; } = string.Empty;
			public string text { get; set; } = string.Empty;
		}
		
		//******************************************** 
		// AsyncHttpAPIs用クラス
		//********************************************
		public class DurableAsyncHTTPAPIs
		{
			public string Id { get; set; }
			public string StatusQueryGetUri { get; set; }
			public string SendEventPostUri { get; set; }
			public string TerminatePostUri { get; set; }
		}
		
		//********************************************
		// StatusQueryGetUriレスポンス用クラス
		//********************************************
		public class StatusQueryResponse
		{
			public string runtimeStatus { get; set; }
			public object input { get; set; }
			public List<string> output { get; set; }
			public DateTime createdTime { get; set; }
			public DateTime lastUpdatedTime { get; set; }
		}
	}
}

次にローカル環境でLineSimulatorとDurableFunctionを繋ぐIF用のFunctionsを作成します。

ちょっと長いですがIF用のFunctionのサンプルソースを記載してい置くので参考にしてみてください。やっていることを簡単に説明するとLineから何かしらのメッセージを受けたら指定先のDurableFunctionsの実行指示をだして処理プロセス確認用のURLを受け取り、そのURLをボタンテンプレートのpostbackに含めて作成してLine側に返します。その後、Line側で確認用のボタンが押されたら実行上状況を確認してLineにメッセージとして返します。

※改行文字で嵌りまして色々試してみた結果<br>タグでした・・・。\r\nとかじゃないのね・・・。

とりあえず実行するとこんな感じになります。VSにコンソールにと色々起動してますが4Kモニターとデュアルデイスプレイだと捗ります。

ローカルで開発できるので便利だなぁと思ってやってみたのですがLineSimulatorをnpmで立ち上げてVisual Studioを立ち上げてFunctionをFunctionCliでローカル実行してとかやっていると画面の行き来が大変になってきます。まぁとはいえLineBotの開発をローカル環境で完結できるのは非常にメリットでかいと思います。さらにVisual Sutdioを2つ起動すればFunctionsもDurableFunctionsもローカルで開発できるので全てローカルで完結できるのはありがたいことです。ネットが繋がらない環境でも開発できますよ。

今回はFunctionsの一般的なプロジェクトテンプレートを利用して作成しましたがVisual Studio用に簡単にLineBotが作成できるライブラリがあるのでちょこっとご紹介しておきます。こちらのライブラリを利用するとLineBotのFunctionも簡単に作れちゃったりするので便利ですよ。

github.com

marketplace.visualstudio.com

最後に恐縮ですが宣伝です。

Auzreもくもく会を主宰しています。次回は2018年1月6日の予定なのでご都合よろしい方は是非ご参加ください。一緒にAzureを研鑽しましょう。

azure-moku2.connpass.com

コメント