2020年3月14日 星期六

幫 Google Assistant 加上更多語言

上一篇我們成功做出一個會跟我們猜數字的 Assistant,不過因為方便我開發的時候都是用英文在開發,自己玩玩當然 OK,但對非英文使用者就不行了,因此我們來試著加上中文的回應。
目前 google assistant 支援 20 種語言(註),在 assistant 的開發頁面選擇 modify language setting 可以打開想要的語言,我這裡先開英文和繁體中文,兩個語言都要設定 assistant 的呼叫詞,中文的我們就叫它「猜數字程式」。

在連接的 dialogflow 的部分,點選 project 旁邊的小齒輪,language 分頁裡面也加上繁體中文的選項,在左側欄的 project 名稱下面就可以看到有兩個不同的語言。

加完語言之後,最麻煩的就是把整個設定複製一份了,所有的 intent 設定在新的語言都要再設定一遍,或者你在不同的語言想要採取不同的 flow 也可以,因為猜數字很簡單,我英文跟中文就用相同設定,training phrase 的部分,直接打上中文就可以了。


設定完 dialogflow 之後,下一步要設定我們的 webhook 讓它也能處理多國語言,我選用的套件是 python 的 python-i18n,可以用 yml 或 json 格式儲存想要翻譯的文字,這樣我們程式碼幾乎結構不用大修,只要在回應的地方呼叫 i18n 幫我們吐出翻譯過的文字就好。
修改後的目錄長成這樣:
main.py
locales
  | guess.en.json
  | guess.zh-tw.json
把文件檔都放在 locales 下面,檔案名稱為:{title}.{lang}.json,英文的 guess.en.json 內容如下:
{
  "en": {
    "test" : "test",
    "welcome" : "I have a number between %{low} and %{high}. Can you guess it?",
    "guess_out": "Are you sure? I said a number between %{low} and %{high}",
    "guess_unmatch" : "A number between %{low} and %{high}. Keep guess."
  }
}
對應的中文則是 guess.zh-tw.json:
{
  "zh-tw": {
    "test" : "測試字串",
    "welcome" : "我有一個介於 %{low} 到 %{high} 的數字,你能猜到嗎?",
    "guess_out": "你確定嗎?我說介於 %{low} 跟 %{high} 之間",
    "guess_unmatch" : "介於 %{low} 跟 %{high} 之間,再接再勵"
  }
}
開頭是 language code,這邊這個名字要跟檔案的 {lang} 是相同的,雖然說有點多此一舉的感覺,內容則是 key-value 的形式儲存文字內容。

主程式的部分也要對應的修改:
import i18n

i18n.load_path.append('locales')
i18n.set('file_format', 'json')
i18n.set('fallback', 'en')

language_code = data.get("queryResult").get("languageCode")
print(language_code)
i18n.set('locale', language_code)
text = i18n.t('guess.welcome', low = str(low), high = str(high))
因為我們把檔案都放在 locales 下面,在搜尋路徑上加上 locales;檔案格式為 json;預設的語言是英文。
在 assistant 的 request 裡面,語言設定會放在 queryResult -> languageCode 下,用 i18n.set 設定 locale;這時呼叫 i18n.t(title.key) 就會找出在 {title}.{lang}.json 檔案裡,{lang} 下面 key 對應的字串了,i18n.t 的參數,則可以代入預先設定好的 placeholder 代換到字串裡。

測試一下,在測試頁面可以選擇測試的語言,選擇繁體中文來試試:

現在我們的人工智障會講中文了。

附註:在設定頁面全部可選的語言(依英文首字排序)有:繁中、粵、丹、荷、法、德、印度、印尼、義、日、韓、挪、波蘭、葡、俄、西、瑞典、泰、土;其中西、法、英三大家有口音選項。
雖然在寫這篇的時候我有查到去年 12 月的新聞,說 google assistant 會支援 44 種語言,但截至我寫這個機器人的時候,還沒有這麼多的語言可以設定,可能是新聞跑得比開發工具快吧。另外 google assistant 的說明文件上,則是沒有這麼多種語言,可能開發工具又跑得比文件快一點。
結論:宣傳 > 開發工具 > 開發文件(欸

2020年3月1日 星期日

Rust std process 生成子行程

最近在玩 Rust 的時候,需要用 Rust 去呼叫一些 shell command 來幫我完成一些事,幸好 Rust std 裡面已經有 process 來幫我們完成這件事,使用起來很像 python 的 subprocess,不過實際在用遇到一些問題,所以寫個筆記記錄一下:

首先當然是從 std 引入這個模組的 Command,Stdio 很常用也順便 include 一下:
use std::process::{Command, Stdio};

一切的基礎就是一行 Command::new(command_name),在 command_name 的地方填入你想呼叫的指令。
Command 代表了一個準備好要跑的命令,就像是在 shell 裡面打下 command_name 直接按 enter 一樣,沒有參數、繼續現在行程的環境、位置和現在行程的位置相同。
如果要設定給命令的參數,就用 .arg 塞進去,如下面的例子:
let mut ls = Command::new(ls).arg("-al");
這個參數一次只能塞一個,有多個參數要連續呼叫 .arg 才行。

有個 Command 之後接下來有三種方式讓它跑起來:.spawn(), .output(), .status():

  • spawn fork 子行程執行,拿到一個子行程的 handler,回傳的型別是 Result<Child>。
  • output fork 子行程執行,等待(wait)它結束之後,收集它寫到 std output 的內容,回傳的型別是 Result<Output>。
  • status fork 子行程執行,等待它結束之後,收集它回傳的資訊,回傳的型別是 Result<ExitStatus>。

第一個可以注意到的是回傳的型別都是 Result,這是因為 command 可能會跑起來也可能會跑不起來,像是我打一個 Command::new("www") 但我的 shell 根本沒 www 這個指令,Result 提醒了這個可能性的存在,一般來說這邊最簡單的就是用 .expect 把 Result 解開。
第二個另人疑惑的,是後面的 Child, Output, ExitStatus 是什麼鬼,整理之下大概是這樣:

  • ExitStatus 是最簡單的,就是行程結束的狀態的封裝,Rust 提供兩個介面 success 跟 code 來判斷子行程有沒有正常結束以及對應的 exit code。
  • Output 是更上一層,裡面包了一層 status : ExitStatus,加上兩個 stdout, stderr 的 Vec<u8>,裡面存了子行程所有寫到 stdout 跟 stderr 的內容。
  • 最外層就是由 spawn 產生的 Child,比起 output 跟 status 一生成行程就自動幫你 wait,spawn 給了完全的操作能力,可以做更多事情。

三個啟動的函式影響最大的就是子行程的 stdin/stdout/stderr,在 spawn 跟 status 下,stdin/stdout/stderr 會繼承父行程的 stdin/stdout/stderr;在 output 時,stdin 會被設定成不可使用(接到 /dev/null), stdout 跟 stderr 則會設定成 piped 來讀取。
如果不想用預設的設定,可以在呼叫 status/output/spawn 前做設定,有三個選項可選 Stdio::inherit、Stdio::piped、Stdio::null,分別就是繼承父行程、接 piped 到父行程跟接上 /dev/null 。

現在就能來玩一些例子,例如在 rust 裡面呼叫 ls,用 status() 的話輸出會直接輸出到螢幕上面:
let p = Command::new("ls")
    .arg("-al")
    .status()
    .expect("ls command failed to start");
drwxr-xr-x 5 yodalee yodalee 4096 2月 29 09:45 .
drwxr-xr-x 16 yodalee yodalee 4096 2月 28 20:10 ..
-rw-r--r-- 1 yodalee yodalee 62279 2月 29 00:07 Cargo.lock
-rw-r--r-- 1 yodalee yodalee 312 2月 29 00:07 Cargo.toml

如果想要把 ls 的內容截下來的話,就要改用 output:
let p = Command::new("ls")
    .arg("-al")
    .output()
    .expect("ls command failed to start");
這時候可以從 p.stdout 裡面拿到 Vec<u8>,要轉成字串就要用 String 的 from_utf8/from_utf8_lossy/from_utf8_unchecked 函式轉。
let s = from_utf8_lossy(&p.stdout);
println!("{}", s);
drwxr-xr-x 5 yodalee yodalee 4096 2月 29 09:45 .
drwxr-xr-x 16 yodalee yodalee 4096 2月 28 20:10 ..
-rw-r--r-- 1 yodalee yodalee 62279 2月 29 00:07 Cargo.lock
-rw-r--r-- 1 yodalee yodalee 312 2月 29 00:07 Cargo.toml

如果要對子行程上下其手有完全的操控,就要使用 spawn 了,不過相對來說也要小心,因為 spawn 不會自動幫你 wait,不小心就會把子行程變殭屍行程。
產生出來的 Child 物件,本身就自帶一些函式,像

  • kill() 發 SIGKILL 把子行程砍了。
  • wait()、wait_output() 等待子行程結束,spawn + wait/wait_with_output 就相當於直接呼叫 status/output。

我們用 shell 的 rev 當作例子,它會輸入 stdin 反轉之後輸出,這裡不能用 output() 因為 output 的 stdin 不會打開;可以用 status ,這樣 stdin 會繼承本來的 shell 的 stdin 讓我們打字,但如果我們是要反轉程式裡面的一行字串呢?這時候我們就要用 s.chars().rev().collect::<String>() 然後這篇文就不用寫了 spawn 再操作 stdin 了。

具體來說大概像是這樣:
let mut p = Command::new("rev")
    .stdin(Stdio::piped())
    .spawn()
    .expect("rev command failed to start");
let stdin = p.stdin.as_mut().expect("Failed to open stdin");
stdin.write_all("Hello".as_bytes()).expect("Failed to write stdin");

本來用 spawn 的話子行程的 io 會繼承父行程的,相當於上面那行改成 .stdin(Stdio::inherit()),這裡我們改用 Stdio::piped() 把它接出來。
接著我們可以從 p (型別是 process::Child)裡去取得它的 stdin, stdout, stderr,這個拿到的都是 Option 型別,用 expect 把它給解開來,裡面就會拿到 Rust 的 io 物件,可以用呼叫對應write 系列函式對它寫入內容,這裡用 write_all 對 stdin 寫入 "Hello" 的 Vec<u8>。
在 stdout 螢幕上就會看到 "olleH" 的輸出了。

當然我們也可以在呼叫的時候把 stdout 也導向 piped 處理,讓我們讀出反轉的結果:

let mut p = Command::new("rev")
    .stdin(Stdio::piped())
    .stdout(Stdio::piped())
    .spawn()
    .expect("rev command failed to start");

let stdin = p.stdin.as_mut().expect("Failed to open stdin");
stdin.write_all("Hello".as_bytes()).expect("Failed to write stdin");
let output = p.wait_with_output().expect("Failed to read stdout");
let revs = String::from_utf8_lossy(&output.stdout);
assert_eq!(revs, "olleH");

以上大概就是 Rust std process 使用方法的整理了,我自己大概有三點感想:
  1. 用 Rust 寫其實沒有比 C 用 fork/exec 來寫來得簡單多少,畢竟我們就是要操作子行程,底層都是系統程式那套,Rust 頂多是封裝得比較完善一點,實際上用起來該設定的一個少不了。
  2. 要寫系統程式,系統程式的概念少不了,要寫 process 至少需要知道作業系統行程的概念(不然一不小心會變成 World War Z 殭屍產生器),操作輸入輸出需要大略知道 file descriptor 的概念,不然文件的繼承 stdin/stdout/stderr,piped 根本看不懂,不管你用哪套語言哪個作業系統,這些基本知識是逃不掉的。
  3.  雖然如此,我覺得 Rust 仍然提供了一套不錯的封裝,在函式的回傳值上套用 Result/Option 的方式,能有效提醒使用者可能發生的錯誤,並要求使用者必須處理他們,這點我認為是花了差不多的成本之後,Rust 唯一可以勝過 C 的地方。
不小心寫了落落長,如果你竟然看到這行了,希望這篇文章對你有幫助XD。