(Swift) NSTask를 이용한 외부 프로세스 이용

외부 프로그램 연동하기

스크립트 내에서 외부 프로그램을 실행하고 그 결과를 얻는 방법을 생각해보자. Foundation 내의 NSTask를 이용하면 개별 프로세스를 생성하여 다른 명령을 실행할 수 있다.

여기서는 간단히 ls를 실행해서 그 결과를 뿌려주는 과정을 살펴보겠다.

동기식으로 처리

task 는 세 가지 준비 작업을 미리 해야 한다. 이는 동기/비동기 방식에 상관없이 동일하다.

  1. 실행해야 할 프로그램의 위치 지정
  2. 실행할 때 전달할 arguments 설정
  3. 출력을 받아올 파이프 설정

그리고 실행(launch)한 후에 .waitUntilExit()를 통해서 해당 프로세스가 종료될때까지 기다린다.다음은 예제코드.

let do_ls_pipe: () -> [String] = {
    let pipe = NSPipe()
    let task = NSTask()

    task.launchPath = "/bin/ls"
    task.arguments = []
    task.standardOutput = pipe

    task.launch()
    task.waitUntilExit()

    // 종료후 출력 데이터 처리
    if case let data = pipe.fileHandleForReading.availableData 
            where data.length < 0
    {
        let s = NSString(data:data, encoding:NSUTF8StringEncoding) as! String
        let files = s.characters.split(Character("\n")).map{ String($0) }
        return files
    }
    return []
}

만약 디렉토리에 파일이 몇 개 없다면 ls 명령은 정말 순식간에 끝나기 때문에 task.waitUntilExit()가 없어도 코드가 정상적으로 실행될 수 있다. 하지만 시간이 조금 걸리는 작업을 실행한 경우, 이렇게 프로세스가 끝날 때까지 기다리지 않는다면 파이프를 읽고 나면 파이프가 닫히는데, 해당 프로세스는 닫힌 파이프에 출력을 써 넣으려고 하기 때문에 에러가 날 수 있다.

task.waitUntilExit()는 해당 프로세스가 종료할 때까지 현재 스레드를 블럭한다. 따라서 GUI앱에서는 비동기식으로 처리하여 UI가 얼지 않도록 해야 한다.

비동기 처리

비동기 처리는 프로세스가 종료된 후에 처리할 핸들러를 지정해준다. 핸들러는 NSTask 객체 자신을 받을 클로져를 사용한다.

let pip: () -> Void = {
    let pipe = NSPipe()
    let task = NSTask()

    task.launchPath = "/usr/local/bin/pip"
    task.arguments = ["list"]
    task.standardOutput = pipe

    let handle: (NSTask) -> Void = { task in

    defer { CFRunLoopStop(CFRunLoopGetMain()) } // 해당 핸들러가 다 실행되고 나면 런루프를 멈추고 종료하도록 한다.

    if case let data = task.standardOutput.fileHandleForReading.availableData
                where data.length > 0,
            let s as String = NSString(data:data, encoding:NSUTF8StringEncoding)
    {
        let packages = s.characters.split(Character("\n"))
        .map{ String($0) }
        print(packages)
    } else {
        print("no packages.")
        }
    }

    task.terminationHandler = handle
    task.launch()
}
pip()
print("pip is gather information about packages installed.")
CFRunLoopRun() // 비동기 방식으로 실행될 것이기 때문에 런루프를 돌려서 task의 실행을 기다려줘야 한다.

업데이트 되었습니다.

아래는 Swift3 버전으로 재작성한 코드이다. 몇 가지 변경사항이 있다.

  • Swift3로 오면서 Process 타입으로 완전히 변경되었다.
  • ProcessstandardOutputAny?타입이다. 따라서 파이프로 캐스팅해야 한다.
import Foundation
// ...
let pip: () -> () = {
    let task: Process = {
        let t = Process()
        t.launchPath = "/usr/local/bin/pip"
        t.arguments = ["list"]
        t.standardOutput = Pipe()
        return t
    }()

    let handle: (Task) -> () = { task in
        defer { CFRunLoopStop(CFRunLoopGetMain()) }

        guard task.terminationStatus == 0,
            let data = (task.standardOutput as? Pipe)?.fileHandleForReading.availableData,
            data.count > 0,
            let s = String(data:data, encoding: .utf8) 
                else { return }

        let packages = s.components(separatedBy: "\n")
        for (i, package) in packages.enumerated() {
            print(i, package)
        }
    }

    task.terminationHandler = handle
    task.launch()
}
pip()
print("pip is gathering information about installed packages.")
CFRunLoopRun()

참고로 GUI앱에서 이 방식을 사용한다면, 종료 핸들러가 메인 스레드에서 호출된다는 보장이 없다. 다음과 같이 처리하면 된다. 이는 어떤 zip파일을 열고 그 속의 파일 목록을 테이블 뷰에 뿌려주는 Cocoa 클래스의 예이다.

class AppController: NSObject, NSTableViewDataSource {
    @IBOutlet weak var tableView: NSTableView!
    var filenames: [String] = ()

    func getFilename(in url: URL) {
        let filepath = url.path
        let task = Process()
        task.launchPath = "/usr/bin/zipinfo"
        task.arguments = ["-1", filepath]
        task.standardOutput = Pipe()
        task.terminationHandler = { task in
            guard task.terminationStatus == 0
            else {
                NSLog("The process fail to operate.")
                return
            }

            guard let data = (task.standardOutput as? Pipe)?.fileHandleForReading.availableData,
                data.count > 0,
                let s = String(data: data, encoding: .utf8)
                else { return }

            filenames = s.components(separatedBy: "\n").filter{ !$0.contains("/.git/") }
            // 이 메소드가 메인 스레드에서 호출된다는 보장이 없으므로, UI 업데이트는 여기서 할 것
            DispatchQueue.main.sync{ tableView.reloadData() }
        }
        task.launch()
    }

    @IBAction func openfile(_ sender: Any) {
        let open = NSOpenPanel()
        open.allowedFiletypes = ["zip"]
        open.beginSheetModal(for: tableView.window!){ res in 
            if res == NSFileHandlingPanelOKButton {
                guard let url = open.url else { return }
                self.getFilename(in: url)
            }
        }
    }
}