かずきのBlog@hatena

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

Bot Frameworkでテスタブルな感じに作りたいのでDIしてみた

Bot Frameworkは実はAutofacを使ってるみたいですね。

github.com

ただ、これを使う方法のドキュメントが見つけられない…。サンプルの中には、これを使って作られてるものがあったりもします。

github.com

でも、微妙にInternalsな名前空間使ってたりして、もんにょりするのですがとりあえずDI出来る感じにする手順をやってみたいと思います。因みに、2017/07/05現在の情報なので割とさくっとやり方かわるかもしれないので注意です。

作ってみよう

Bot Frameworkのプロジェクトを作ります。

Update NuGet packages

作ったらAutofacとSystem.IdentityModel.Tokens.Jwtの2つ以外を最新にします。Autofacは3系と4系で.NET Std対応してたりするので怖くて上げてません。Jwtのほうは何かあげれませんでした。

とりあえずサービス作ってみる

今回はDIすることが目的なので、さくっと固定メッセージを返すだけの以下のようなインターフェースとクラスをでっちあげました。

namespace DITestBotApp.Services
{
    public interface IGreetService
    {
        string GetMessage();
    }

    public class GreetService : IGreetService
    {
        public string GetMessage()
        {
            return "これはDIしたサービスから返されたメッセージです。";
        }
    }
}

DIコンテナのセットアップはConversationクラスのUpdateContainerメソッドで行います。Global.asax.csあたりでやるっぽいですね。先ほど作成したGreetServiceを登録しています。ContainerBuilderが渡ってくるのでよしなにするという感じです。ここらへんはAutofacのドキュメント参照って感じですね。

using Autofac;
using DITestBotApp.Dialogs;
using DITestBotApp.Services;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Internals.Fibers;
using System.Web.Http;

namespace DITestBotApp
{
    public class WebApiApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            GlobalConfiguration.Configure(WebApiConfig.Register);

            Conversation.UpdateContainer(builder =>
            {
                builder.RegisterType<RootDialog>()
                    .As<IDialog<object>>()
                    .InstancePerLifetimeScope();

                builder.RegisterType<GreetService>()
                    .Keyed<IGreetService>(FiberModule.Key_DoNotSerialize)
                    .AsImplementedInterfaces()
                    .SingleInstance();
            });
        }
    }
}

んで、ポイントはKeyedメソッドでFilberModule.Key_DoNotSerializeを指定しておくというところです。シリアライズしないでねっていう印みたいです。あとは、IDialog<object>で会話の起点となるルートのDialogを登録しておきます。

RootDialogを以下のように書き換えてGreetServiceをさくっと使うようにしてみました。

using System;
using System.Threading.Tasks;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Connector;
using DITestBotApp.Services;

namespace DITestBotApp.Dialogs
{
    [Serializable]
    public class RootDialog : IDialog<object>
    {
        private IGreetService GreetService { get; }

        // DIする
        public RootDialog(IGreetService greetService)
        {
            this.GreetService = greetService;
        }

        public Task StartAsync(IDialogContext context)
        {
            context.Wait(this.GreetAsync);

            return Task.CompletedTask;
        }

        private async Task GreetAsync(IDialogContext context, IAwaitable<object> result)
        {
            // DIしたやつを使う
            await context.PostAsync(this.GreetService.GetMessage());
            await this.MessageReceivedAsync(context, result);
        }

        private async Task MessageReceivedAsync(IDialogContext context, IAwaitable<object> result)
        {
            var activity = await result as Activity;

            // calculate something for us to return
            int length = (activity.Text ?? string.Empty).Length;

            // return our reply to the user
            await context.PostAsync($"You sent {activity.Text} which was {length} characters");

            context.Wait(MessageReceivedAsync);
        }
    }
}

あとは、MessagesControllerでDIコンテナからDialogを作って渡すように書き換えます。

public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
{
    if (activity.Type == ActivityTypes.Message)
    {
        using (var scope = DialogModule.BeginLifetimeScope(Conversation.Container, activity))
        {
            var dialog = scope.Resolve<IDialog<object>>();
            await Conversation.SendAsync(activity, () => dialog);
        }
    }
    else
    {
        HandleSystemMessage(activity);
    }
    var response = Request.CreateResponse(HttpStatusCode.OK);
    return response;
}

実行してみるとこんな感じに動きます。ちゃんとDIしたやつがいけてますね。

f:id:okazuki:20170705141734p:plain

DialogからDialogを使うケース

DialogからDialogを使うときは、新たにDialogのインスタンスを作ってIDialogContextクラスのCallメソッドに渡してやる必要があります。これはDialogを作るファクトリを作って、こいつがAutofacのクラスを使ってインスタンス作って返す感じです。やってみましょう。

こんなDialogを用意します。

using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Connector;
using System;
using System.Threading.Tasks;

namespace DITestBotApp.Dialogs
{
    [Serializable]
    public class SimpleDialog : IDialog<object>
    {
        public async Task StartAsync(IDialogContext context)
        {
            await context.PostAsync("SimpleDialog started");
            context.Wait(this.HelloWorldAsync);
        }

        private async Task HelloWorldAsync(IDialogContext context, IAwaitable<object> result)
        {
            var input = await result as Activity;
            await context.PostAsync($"Hello world!! {input.Text}");
            context.Done<object>(null);
        }
    }
}

Global.asax.csで、こいつを登録します。

using Autofac;
using DITestBotApp.Dialogs;
using DITestBotApp.Services;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Internals.Fibers;
using System.Web.Http;

namespace DITestBotApp
{
    public class WebApiApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            GlobalConfiguration.Configure(WebApiConfig.Register);

            Conversation.UpdateContainer(builder =>
            {
                builder.RegisterType<RootDialog>()
                    .As<IDialog<object>>()
                    .InstancePerLifetimeScope();

                builder.RegisterType<SimpleDialog>()
                    .InstancePerDependency();

                builder.RegisterType<GreetService>()
                    .Keyed<IGreetService>(FiberModule.Key_DoNotSerialize)
                    .AsImplementedInterfaces()
                    .SingleInstance();
            });
        }
    }
}

ファクトリのクラスを作ります。こいつはIComponentContext(Autofacのインターフェース)を使ってインスタンスを作ります。

using Autofac;

namespace DITestBotApp.Factories
{
    public interface IDialogFactory
    {
        T Create<T>();
    }

    public class DialogFactory : IDialogFactory
    {
        private IComponentContext Scope { get; }

        public DialogFactory(IComponentContext scope)
        {
            this.Scope = scope;
        }

        public T Create<T>()
        {
            return this.Scope.Resolve<T>();
        }
    }
}

これも、DIコンテナに登録します。

using Autofac;
using DITestBotApp.Dialogs;
using DITestBotApp.Factories;
using DITestBotApp.Services;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Internals.Fibers;
using System.Web.Http;

namespace DITestBotApp
{
    public class WebApiApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            GlobalConfiguration.Configure(WebApiConfig.Register);

            Conversation.UpdateContainer(builder =>
            {
                builder.RegisterType<DialogFactory>()
                    .Keyed<IDialogFactory>(FiberModule.Key_DoNotSerialize)
                    .AsImplementedInterfaces()
                    .InstancePerLifetimeScope();

                builder.RegisterType<RootDialog>()
                    .As<IDialog<object>>()
                    .InstancePerLifetimeScope();

                builder.RegisterType<SimpleDialog>()
                    .InstancePerDependency();

                builder.RegisterType<GreetService>()
                    .Keyed<IGreetService>(FiberModule.Key_DoNotSerialize)
                    .AsImplementedInterfaces()
                    .SingleInstance();
            });
        }
    }
}

では、RootDialogでSimpleDialogを作るようにしてみよう。

using System;
using System.Threading.Tasks;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Connector;
using DITestBotApp.Services;
using Autofac;
using DITestBotApp.Factories;

namespace DITestBotApp.Dialogs
{
    [Serializable]
    public class RootDialog : IDialog<object>
    {
        private IGreetService GreetService { get; }
        private IDialogFactory Factory { get; }

        // DIする
        public RootDialog(IDialogFactory factory, IGreetService greetService)
        {
            this.Factory = factory;
            this.GreetService = greetService;
        }

        public Task StartAsync(IDialogContext context)
        {
            context.Wait(this.GreetAsync);

            return Task.CompletedTask;
        }

        private async Task GreetAsync(IDialogContext context, IAwaitable<object> result)
        {
            // DIしたやつを使う
            await context.PostAsync(this.GreetService.GetMessage());
            await this.MessageReceivedAsync(context, result);
        }

        private async Task MessageReceivedAsync(IDialogContext context, IAwaitable<object> result)
        {
            var activity = await result as Activity;

            if (activity.Text == "change")
            {
                context.Call(this.Factory.Create<SimpleDialog>(), this.ResumeSimpleDialogAsync);
            }
            else
            {
                // calculate something for us to return
                int length = (activity.Text ?? string.Empty).Length;

                // return our reply to the user
                await context.PostAsync($"You sent {activity.Text} which was {length} characters");

                context.Wait(MessageReceivedAsync);
            }
        }

        private async Task ResumeSimpleDialogAsync(IDialogContext context, IAwaitable<object> result)
        {
            await context.PostAsync("returned");
            context.Wait(this.MessageReceivedAsync);
        }
    }
}

因みに、最初はDialogFactory作らずにIComponentContextを直接RootDialogにDIしてやればいいやって思ったのですが、この人をDIさせるとシリアライズできないと怒られてしまいましたので、1枚ラップして明示的にシリアライズしないよってマークしたやつをRootDialogにDIするようにしました。

実行してみましょう。

f:id:okazuki:20170705151116p:plain

いい感じですね。

ソースコードは以下の場所に置いてあります。

github.com