用 Swift 來寫命令列程式

SwiftGG發表於2016-03-28

這是探索 Swift 寫 Linux 程式的系列文章中的一篇。

在上一個例子中,我們通過組合使用 popen 和 wget 命令來呼叫自然語言翻譯服務,來實現像 Google 翻譯那樣的翻譯功能。本文的程式會基於之前我們已經完成的工作來進行。但與之前每次執行都只能翻譯一句話所不同的是,這次我們要實現一個具備互動功能的 shell 程式,來翻譯在控制檯輸入的每一句話。像下面的截圖一樣:

翻譯程式會顯示它接受什麼語言(源語言)以及翻譯的目標語言。比如:

  • en->es 英語翻譯為西班牙語
  • es->it 西班牙語翻譯為義大利語
  • it->ru 義大利語翻譯為俄羅斯語

翻譯程式預設是 en->es,並提供了兩個命令:to 和 from 來實現語言的切換。比如,輸入 to es 將會把翻譯的目標語言設定為西班牙語。輸入 quit 可以退出程式。

如果使用者輸入的字串不是命令的話,翻譯程式會把輸入逐字地傳送到翻譯的 web 服務。然後把返回的結果列印出來。

需要注意的幾點

如果你是系統或者運維程式設計師,並且以前也沒接觸過 Swift 的話,下面是一些你在程式碼裡需要注意的事情。我想你會發現 Swift 為兩種型別的工程師都提供了很多有用的特性,並且會成為 Linux 開發體系中一股很受歡迎的新力量。

  • let variable = value 常量賦值
  • 元組(tuples)
  • switch-case 支援字串
  • switch-case 使用時必須包含所有情況(邏輯完備性)
  • 計算型屬性
  • import Glibc 可以匯入標準的 C 函式
  • guard 語句
  • 可以使用 NSThread 和 NSNotificationCenter 這些蘋果的 Foundation 框架中的類。
  • 在不同的執行緒或不同的物件裡通過傳送訊息來觸發特定程式碼的執行

程式設計

我們的翻譯程式可以拆分成一個主程式、兩個類以及一個 globals.swift 檔案。如果你打算跟著做,那你應該使用 Swift 的包管理器,然後調整你的目錄結構為下面這樣:

translator/Sources/main.swift
          /Sources/CommandInterpreter.swift
          /Sources/...
          /Package.swift

main.swift 檔案是 Swift 應用程式的入口並且應該是唯一一個包含可執行程式碼的檔案(在這裡,像「變數賦值」,或者「宣告一個類」不屬於「可執行的程式碼」)。

main.swift

import Foundation
import Glibc

let interpreter = CommandInterpreter()
let translator  = Translator()

// Listen for events to translate
nc.addObserverForName(INPUT_NOTIFICATION, object:nil, queue:nil) {
  (_) in
  let tc = translationCommand
  translator.translate(tc.text, from:tc.from, to:tc.to){
    translation, error in
    guard error == nil && translation != nil else {
      print("Translation failure:  \(error!.code)")
      return
    }
    print(translation!)
  }
}

interpreter.start()

select(0, nil, nil, nil, nil)

上面的程式碼表示我們的程式不接受命令列引數。具體的流程說明:

  1. 分別建立 CommandInterpreter 和 Translator 類的例項
  2. 為 InputNotification 通知新增觀察者(這裡用到的常量 INPUT_NOTIFICATION 常量定義在 globals.swift
  3. 新增當收到通知的時候要執行的程式碼
  4. 呼叫 Interpreter 類例項的 start 方法
  5. 呼叫 select 來實現當程式有其他執行緒在執行的時候,鎖定主執行緒。(譯註:也就是防止主執行緒提前結束)

CommandInterpreter 類

CommandInterpreter 類主要負責從終端讀入輸入的字串,並且分析輸入的型別並分別進行處理。考慮到你可能剛接觸 Swift,我在程式碼裡對涉及到語言特性的地方進行了註釋。

// Import statements
import Foundation
import Glibc

// Enumerations
enum CommandType {
case None
case Translate
case SetFrom
case SetTo
case Quit
}

// Structs
struct Command {
  var type:CommandType
  var data:String
}

// Classes
class CommandInterpreter {

  // Read-only computed property
  var prompt:String {
    return "\(translationCommand.from)->\(translationCommand.to)"
  }

  // Class constant
  let delim:Character = "\n"

  init() {
  }

  func start() {
    let readThread = NSThread(){
      var input:String    = ""

      print("To set input language, type 'from LANG'")
      print("To set output language, type 'to LANG'")
      print("Type 'quit' to exit")
      self.displayPrompt()

      while true {
        let c = Character(UnicodeScalar(UInt32(fgetc(stdin))))
        if c == self.delim {
          let command = self.parseInput(input)
          self.doCommand(command)
          input = "" // Clear input
          self.displayPrompt()
        } else {
          input.append(c)
        }
      }
    }

    readThread.start()
  }

  func displayPrompt() {
    print("\(self.prompt):  ", terminator:"")
  }

  func parseInput(input:String) -> Command {
    var commandType:CommandType
    var commandData:String = ""

    // Splitting a string
    let tokens = input.characters.split{$0 == " "}.map(String.init)

    // guard statement to validate that there are tokens
    guard tokens.count > 0 else {
      return Command(type:CommandType.None, data:"")
    }

    switch tokens[0] {
    case "quit":
      commandType = .Quit
    case "from":
      commandType = .SetFrom
      commandData = tokens[1]
    case "to":
      commandType = .SetTo
      commandData = tokens[1]
    default:
      commandType = .Translate
      commandData = input
    }
    return Command(type:commandType,data:commandData)
  }

  func doCommand(command:Command) {
    switch command.type {
    case .Quit:
      exit(0)
    case .SetFrom:
      translationCommand.from = command.data
    case .SetTo:
      translationCommand.to   = command.data
    case .Translate:
      translationCommand.text = command.data
      nc.postNotificationName(INPUT_NOTIFICATION, object:nil)   
    case .None:
      break
    }
  }
}

CommandInterpreter 類的實現邏輯非常直觀。當 start 函式被呼叫的時候,通過 NSThread 來建立一個執行緒,執行緒中再通過 blockfgetc 的回撥引數 stdin 來獲取終端的輸入。當遇到換行符 RETURN(使用者按了回車)後,輸入的字串會被解析並對映成一個 Command 物件。然後傳遞給 doCommand 函式進行剩下的處理。

我們的 doCommand 函式就是一個簡單的 switch-case 語句。對於 .Quit 命令則就簡單呼叫 exit(0) 來終止程式。.SetFrom 和 .SetTo 命令的功能是顯而易見的。當遇到 .Translate 命令時,Foundation 的訊息系統就派上用場了。doCommand 函式自己並不完成任何的翻譯功能,它只是簡單的傳送一個應用程式級別的訊息,也就是InputNotification。任何監聽這個訊息的程式碼都會被呼叫(比如我們之前的主執行緒):

// Listen for events to translate
nc.addObserverForName(INPUT_NOTIFICATION, object:nil, queue:nil) {
  (_) in
  let tc = translationCommand
  translator.translate(tc.text, from:tc.from, to:tc.to){
    translation, error in
    guard error == nil && translation != nil else {
      print("Translation failure:  \(error!.code)")
      return
    }
    print(translation!)
  }
}

我在這篇文章中提到,在對 NSNotification 的 userInfo 字典做型別轉換時會有一個 SILGen 的閃退 crash,在這裡我們用一個叫做 translationCommand 的全域性變數來繞過這個 crash。在這段程式碼裡:

  1. 為了程式碼的簡潔,把 translationCommand 的內容賦值給 tc
  2. 呼叫 Translator 物件的 translate 方法,並傳入相關的引數
  3. 實現翻譯完成後的回撥
  4. 用一個 Swift 漂亮的 guard 語句來檢測是否有錯並返回
  5. 列印出翻譯的文字

Translator

Translator 類最開始是在這篇文章中介紹的,我們在這裡直接重用:

import Glibc
import Foundation
import CcURL
import CJSONC

class Translator {

  let BUFSIZE = 1024

  init() {
  }

  func translate(text:String, from:String, to:String,
                        completion:(translation:String?, error:NSError?) -> Void) {

    let curl = curl_easy_init()

    guard curl != nil else {
      completion(translation:nil,
                 error:NSError(domain:"translator", code:1, userInfo:nil))
      return
    }

    let escapedText = curl_easy_escape(curl, text, Int32(strlen(text)))

    guard escapedText != nil else {
      completion(translation:nil,
                 error:NSError(domain:"translator", code:2, userInfo:nil))
      return
    }

    let langPair = from + "%7c" + to
    let wgetCommand = "wget -qO- http://api.mymemory.translated.net/get\\?q\\=" + String.fromCString(escapedText)! + "\\&langpair\\=" + langPair

    let pp      = popen(wgetCommand, "r")
    var buf     = [CChar](count:BUFSIZE, repeatedValue:CChar(0))

    var response:String = ""
    while fgets(&buf, Int32(BUFSIZE), pp) != nil {
      response = response + String.fromCString(buf)!
    }

    let translation = getTranslatedText(response)

    guard translation.error == nil else {
      completion(translation:nil, error:translation.error)
      return
    }

    completion(translation:translation.translation, error:nil)
  }

  private func getTranslatedText(jsonString:String) -> (error:NSError?, translation:String?) {

    let obj = json_tokener_parse(jsonString)

    guard obj != nil else {
      return (NSError(domain:"translator", code:3, userInfo:nil),
             nil)
    }

    let responseData = json_object_object_get(obj, "responseData")

    guard responseData != nil else {
      return (NSError(domain:"translator", code:3, userInfo:nil),
              nil)
    }

    let translatedTextObj = json_object_object_get(responseData,
                                                   "translatedText")

    guard translatedTextObj != nil else {
      return (NSError(domain:"translator", code:3, userInfo:nil),
              nil)
    }

    let translatedTextStr = json_object_get_string(translatedTextObj)

    return (nil, String.fromCString(translatedTextStr)!)

  }

}

整合各個部分

要把上面介紹的元件結合到一起,我們還需要建立額外的兩個檔案:globals.swift 和 Package.swift

globals.swift

import Foundation

let INPUT_NOTIFICATION   = "InputNotification"
let nc = NSNotificationCenter.defaultCenter()

struct TranslationCommand {
  var from:String
  var to:String
  var text:String
}

var translationCommand:TranslationCommand = TranslationCommand(from:"en",
                                                               to:"es",
                                                               text:"")

Package.swift

import PackageDescription

let package = Package(
  name:  "translator",
  dependencies: [
    .Package(url:  "https://github.com/iachievedit/CJSONC", majorVersion: 1),
    .Package(url:  "https://github.com/PureSwift/CcURL", majorVersion: 1)
  ]
)

如果一切都配置正確的話,最後執行 swift build,一個極具特色的翻譯程式就完成了。

swift build
Cloning https://github.com/iachievedit/CJSONC
Using version 1.0.0 of package CJSONC
Cloning https://github.com/PureSwift/CcURL
Using version 1.0.0 of package CcURL
Compiling Swift Module 'translator' (4 sources)
Linking Executable:  .build/debug/translator

如果你打算從現成的程式碼開始學習,可以從 Github 上獲取本站的程式碼,然後找到 cmdline_translator 目錄。

試試自己動手

現在的翻譯程式還有很多可以優化的地方。下面是一個你可以嘗試的列表:

  • 接受命令列引數來設定預設的源語言和目標語言
  • 接受命令列引數來實現非互動模式
  • 新增 swap 命令來交換源語言和目標語言
  • 新增 help 命令
  • 整合 from 命令和 to 命令。實現一行可以同時設定兩者, 比如 from en to es
  • 現在當輸入 from 命令和 to 命令時,沒有同時輸入對應的語言時會崩潰,修復這個BUG
  • 實現對轉義符 \ 的處理,實現程式的“命令”也可以被翻譯(比如退出命令:quit)
  • 通過 localizedDescription 對錯誤提示新增本地化的支援
  • 在 Translator 類中實現但有錯誤發生時,通過 throws 來處理異常

結束語

我從來不掩飾我是一個狂熱的 Swift 愛好者,我堅信它很可能既能像 Perl、Python 和 Ruby 這樣語言一樣出色的完成運維工作,也能像 C、C++ 和 Java 一樣出色的完成系統程式設計的任務。我知道現在和那些個單檔案指令碼語言相比,Swift 比較蛋疼的一點就是必須得編譯成二進位制檔案。我真誠的希望這一點能夠改善,這樣我就能不再關注語言層面的東西而是去做一些新,酷酷的東西。一些朋友已經在 Swift 的郵件列表中討論這一點,具體可以看這個帖子

相關文章