[Swift] Swiftで簡単なCLIを作ってみる (その3)

はじめに

CX事業本部の中安です。

今回は [Swift] Swiftで簡単なCLIを作ってみる (その2) の続きです。

前回は、ProcessPipeを使って、他のコマンドを実行してみるというところまでを書きました。 ソースコードがベタ書きだったので、今回はそれを整理して、複数のコマンドを繋げられるところまでやってみたいと思います。

前回まで

おさらいですが、ProcessPipeというクラスを使用して、 ダミーなWebAPIからJSONを取得して、人の目に優しい形で出力するところまでを行いました

ソースコードはこんな感じです。

import Foundation
  
let pipe = Pipe()
  
let process = Process()
process.launchPath = "/usr/bin/curl"
process.arguments = ["http://dummy.restapiexample.com/api/v1/employees", "-s"]
process.standardOutput = pipe
process.launch()
process.waitUntilExit()
  
let data = pipe.fileHandleForReading.readDataToEndOfFile()
  
do {
    let decoder = JSONDecoder()
    let decodedData = try decoder.decode([[String:String]].self, from: data)
    let encoder = JSONEncoder()
    encoder.outputFormatting = .prettyPrinted
    let encodedData = try encoder.encode(decodedData)
    let encodedString = String(data: encodedData, encoding: .utf8) ?? "error."
    print(encodedString)
}

今回は、JSONエンコーダ/デコーダを使ってJSON文字列を整形している部分を jqコマンドを使って整形をしてみたいと思います。

jq コマンドが使えない場合は事前にインストールしておいてください。

https://stedolan.github.io/jq/

Processを繋げられるようにする

ProcessPipe を使って処理を繋げることができます。

例えば普通にコマンドを打つとすれば下記のようなイメージです

$ curl "http://dummy.restapiexample.com/api/v1/employees -s | jq`

Pipeはここでいうところの | にあたります。

ただ、これをベタに書いていくと冗長なソースコードになってしまいます。 そこで、extensionを使って、Process同士をPipeでつなぐのを楽にしてみたいと思います。

Processのイニシャライザを追加する

let process = Process()
process.launchPath = "/usr/bin/curl"
process.arguments = ["http://dummy.restapiexample.com/api/v1/employees", "-s"]

Processを作成するとき、上のようなlaunchPathargumentsへの代入はどうせ行う処理です。 コンビニエンスイニシャライザを使って、初期化時に与えられるようにしておきます。

extension Process {
    
    convenience init(_ launchPath: String, _ arguments: [String]? = []) {
        self.init()
        self.launchPath = launchPath
        self.arguments = arguments
    }
}

これにより、Processは以下のように生成できます。

Process("/usr/bin/curl", ["http://dummy.restapiexample.com/api/v1/employees", "-s"])

この例ではメソッドのラベルを取ってしまいましたが、もちろん付けてもいいと思います。

出力データを取得する

process.standardOutput = pipe
process.launch()
process.waitUntilExit()
   
let data = pipe.fileHandleForReading.readDataToEndOfFile()

上のようなPipeを用いてバッファを取り出す機能もProcessに加えておきます。

extension Process {
    
    func output() -> Data {
        let pipe = Pipe()
        standardOutput = pipe
        
        launch()
        waitUntilExit()
         
        return pipe.fileHandleForReading.readDataToEndOfFile()
    }
}

これに加えて、出力を文字列化する処理も足しておきます。 (出力が文字列じゃないときや、データだけ欲しいときのことも考慮してメソッドは分けておきます)

extension Process {
    
    func outputString() -> String {
        let data = output()
        return String(data: data, encoding: .utf8) ?? ""
    }
}

こままでで、今までの処理は下記のように2行に収まるようになりました

let str = Process("/usr/bin/curl", ["http://dummy.restapiexample.com/api/v1/employees", "-s"]).outputString()
print(str)

パイプで繋げられるようにする

冒頭でも書いたようにPipeはコマンドでいうところの | にあたります。 なので、プログラムも|Process同士を繋げられれば直感的かなと考えました。

ということで、|を演算子として定義することにします

extension Process {
    
    static func | (lhs: Process, rhs: Process) -> Process {
        let pipe = Pipe()
        lhs.standardOutput = pipe
        rhs.standardInput = pipe
        
        lhs.launch()
        return rhs
    }
}

ここで行っているのは、左辺のProcessの出力を右辺のProcessの入力に繋いでることです。

Processlaunchしないと次のProcessに渡せないとのことなので、 Pipeに繋いだ時点で左辺のProcesslaunchすることにしました。

さて、jqコマンドをソースコード上に展開するには、curl同様にパスを取得しておかないといけません。

$ which jq
/usr/local/bin/jq

で、先程のソースコードに追記するとこのようになります。

let str = (
     Process("/usr/bin/curl", ["http://dummy.restapiexample.com/api/v1/employees", "-s"]) |
     Process("/usr/local/bin/jq", [""])
).outputString()
print(str)

ターミナルjqコマンドは引数がなくても動作するのですが、実は引数として空文字を与えないといけないっぽいです。

ということで、これを実行すると

[
  {
    "id": "1",
    "employee_name": "H-001",
    "employee_salary": "10000",
    "employee_age": "100",
    "profile_image": ""
  },
  {
    "id": "94054",
    "employee_name": "ww",
    "employee_salary": "222",
    "employee_age": "1987",
    "profile_image": ""
  },
  :
  :
 (略)

最後に

Processextensionして、前回までの実装をシンプルにしました。 後々でまた使えそうです。

ここまでのソースコードをまとめると

import Foundation

extension Process {
    
    convenience init(_ launchPath: String, _ arguments: [String]? = []) {
        self.init()
        self.launchPath = launchPath
        self.arguments = arguments
    }
    
    static func | (lhs: Process, rhs: Process) -> Process {
        let pipe = Pipe()
        lhs.standardOutput = pipe
        rhs.standardInput = pipe
        
        lhs.launch()
        return rhs
    }
    
    func output() -> Data {
        let pipe = Pipe()
        standardOutput = pipe
        
        launch()
        waitUntilExit()
         
        return pipe.fileHandleForReading.readDataToEndOfFile()
    }

    func outputString() -> String {
        let data = output()
        return String(data: data, encoding: .utf8) ?? ""
    }
}

let str = (
    Process("/usr/bin/curl", ["http://dummy.restapiexample.com/api/v1/employees", "-s"]) |
    Process("/usr/local/bin/jq", [""])
    ).outputString()
print(str)

こんな感じです。

もうしばらく続きます。ではー