在上一篇介紹完FormFlow之後,我們需要回來看一下目前最大的問題,也就是程式碼都寫在一隻RootDialog裡面。
BotBuilder有考慮到這件事情,因此内建用IDialog來解決這個問題。
這篇來看看IDialog怎麽做到SoC (Seperation of Concern)。
這篇的程式碼github頁面是alantsai-samples/mhat-hotelbot:blog/chapter-09
在進入IDialog之前,我們先來想一下目前的bot和以前的desktop程式或者web程式有什麽不同。
想象一下,如果今天我們現有功能是寫成一個Desktop的App。
每一個功能我們就會有一個頁面,因此,我們會有:
讓使用者輸入姓名的頁面
讓使用者查飯店的頁面
讓使用者訂房的頁面
而這些頁面之間的跳轉我們是透過按鈕點下去之後出現pop up的方式做完輸入之後,可能按下一個確認鍵把資料儲存起來然後又回到了程式的主頁。
剛剛上面提到的是傳統的app概念,那回到chatbot呢?其實是一樣的概念,只不過我們不是透過按下按鈕來切換功能,而是透過輸入文字、圖片和語音來跳轉(多媒體輸入之後在介紹)
剛剛是使用者面的感受,那從程式的角度呢?不管是desktop還是web,一個頁面就會有一隻獨立的cs檔案,除非你把所有功能都寫在一個頁面上面,要不然多多少少邏輯還是會拆分出來,不會造成一個頁面好多個邏輯,無形之中做到了 SoC。
但是在我們目前的chatbot來説,全部都還是在一起,那麽類似desktop或web每一種頁面一個邏輯的對應東西是什麽?那就是IDialog
傳統desktop和chatbot的對應,來源:https://docs.microsoft.com/en-us/azure/bot-service/dotnet/bot-builder-dotnet-manage-conversation-flow?view=azure-bot-service-3.0
我們用一個例子來學習怎麽使用IDialog來切分邏輯。
我們第一個功能是取得使用者的名字,這個我們可以重構到另外一個IDialog,我稱之爲NameDialog
我們會經歷以下幾個步奏:
建立出一個NameDialog
把邏輯從RootDialog搬到NameDialog
調整RootDialog - 決定什麽時候進入NameDialog
要建立一個IDialog需要兩件事情:
要加入[Serializable]
要實作IDialog - 其中的T代表會回傳的内容形態
[Serializable]
public class NameDialog : IDialog<string>
{
public Task StartAsync(IDialogContext context)
{
throw new NotImplementedException();
}
}
``
### 把邏輯從RootDialog搬到NameDialog
邏輯很簡單,當這個Dialog觸發的時候,會詢問使用者的名稱,然後把使用者輸入的内容回傳回來。程式碼變成:
...
public async Task StartAsync(IDialogContext context)
{
await context.PostAsync("您的名字是?");
context.Wait(MessageReceivedAsync);
}
private async Task MessageReceivedAsync
(IDialogContext context, IAwaitable result)
{
var message = await result;
context.Done(message.Text);
}
...
這邊發現,本來我們需要有個暫存來知道是不是問過名字這件事,但是透過Dialog這件事情就不需要暫存資料就可以做到了,因爲StartAsync自動分開了。
我們這個Dialog主要邏輯變成詢問名字了 - 當然問到了之後可以直接存起來,或者像我們這樣回傳,然後看呼叫的人怎麽處理。我個人傾向讓呼叫的人決定怎麽處理,這樣能夠更generic。
### 調整RootDialog - 決定什麽時候進入NameDialog
我們在RootDialog還是會判斷有沒有取得過名字,沒有的話就觸發我們NameDialog
...
context.UserData.TryGetValue("Name", out string name);
if(string.IsNullOrEmpty(name))
{
context.Call(new NameDialog(), GreetingAfterAsync);
}
...
我們可以看到,整個判斷問過名字了沒的部分都沒了,全部由NameDialog處理了,然後我們會在那邊取得使用者輸入的姓名,將會在GreetingAfterAsync的callback裡面做處理。
private async Task GreetingAfterAsync(IDialogContext context,
IAwaitable result)
{
var name = await result;
context.UserData.SetValue<string>("Name", name);
await context.PostAsync($"{name} 您好,能夠幫助您什麽");
context.Wait(MessageReceivedAsync);
}
可以看到,NameDialog取得了名稱,在這個callback裡面得到了之後,把這個名稱寫到UserData裡面。
![botframework-emulator_2018-07-13_00-43-05.png](https://d33wubrfki0l68.cloudfront.net/b6a94b69c80fb0b1cf4b94f21a1028f4caf7c042/d22f2/posts/2018/07/2018-07-13-bot-framework-with-ai-cognitive-service-9-refactor-using-idialog-for-better-soc/5bf419d9-bd4f-4244-a0e3-d4aab9a0fc18.png)
測試執行結果
從上面的測試結果可以看出,我們保持了和以前一樣的流程,但是内部邏輯拆開了。我們有一個專門詢問名字的NameDialog,至於取得的名字之後要做什麽,我們交由呼叫這個Dialog的程式去控制,達到SoC。
### 優化NameDialog
到目前爲止,NameDialog的邏輯和未重構前一樣,不過這邊少了一個防呆,也就是如果使用者沒有輸入任何名字怎麽辦?
我們應該要有個邏輯,做這個檢查:
private async Task MessageReceivedAsync
(IDialogContext context, IAwaitable result)
{
var message = await result;
if(string.IsNullOrEmpty(message.Text))
{
await context.PostAsync("請您輸入您的名字");
context.Wait(MessageReceivedAsync);
}
context.Done(message.Text);
}
不過這個時候會遇到另外一個問題,如果使用者一直輸入不符合條件的姓名,他會無限回圈在這裡面。因此,這種類型的重試,都記得要做所謂的只嘗試幾次,避免使用者卡在這個Dialog。
private int Attempt = 3;
private async Task MessageReceivedAsync
(IDialogContext context, IAwaitable result)
{
var message = await result;
if(string.IsNullOrEmpty(message.Text))
{
Attempt = Attempt - 1;
if (Attempt > 0)
{
await context.PostAsync("請您輸入您的名字");
context.Wait(MessageReceivedAsync);
}
else
{
context.Fail(new TooManyAttemptsException("取不到名字"));
}
}
context.Done(message.Text);
}
從這裡可以看出,拆IDialog是很有必要的,想象一下全部寫在RootDialog,不止邏輯看起來複雜,無法做單元測試,同樣邏輯還不能夠重複使用。因此,好好使用Dialog對整個chatbot開發是關鍵。
## 結語
程式設計師最需要注意的就是保持程式碼的“乾净”,而好好使用IDialog就是讓chatbot程式碼乾净的好幫手。
到目前爲止,chatbot的重要觀念都已經介紹完了,細節的部分還有很多,不過以目前所知要開發出一個chatbot已經不是什麽太難的事情了。
在要轉換文章重心之前,要對目前的code做一次大重構,把這篇所學的IDialog概念都使用上。
下一篇將是一個階段性的總結。