러스트 커맨드라인 애플리케이션
러스트는 뛰어난 도구와 급격히 성장하는 생태계, 높은 성능을 갖춘 정적 컴파일 언어입니다. 러스트는 작고, 휴대성이 높으며, 빠르게 실행해야 하는 커맨드라인 애플리케이션을 작성할 때 매우 적합합니다. 또한 커맨드라인 애플리케이션은 러스트 학습을 시작하는 데 훌륭한 교재이기도 하며, 여러분의 팀에 러스트를 소개하는 일종의 매개체가 될 수도 있습니다!
간단한 커맨드라인 인터페이스(command line interface, CLI) 프로그램을 작성하는 것은 러스트를 이제 막 접하고 감을 잡고자 하는 초심자에게 좋은 연습이 됩니다. 이 주제에는 종종 나중에야 깨닫게 되는 다양한 측면이 있습니다.
이 책은 다음과 같이 구성되어 있습니다: 우선 빠르게 튜토리얼을 시작하고 CLI 툴을 완성하게 됩니다. 여러분은 러스트의 몇몇 핵심적인 개념과 CLI 애플리케이션의 주요 특성을 접하게 될 것입니다. 이 중 일부는 두 번째 챕터에서 더욱 자세히 설명합니다.
CLI 애플리케이션에 바로 뛰어들기 전에 마지막으로 짚고 넘어갈 것이 있습니다. 만약 이 책에서 오류를 찾거나, 더 많은 내용을 담을 수 있도록 돕고 싶다면, CLI 책 저장소 (원본 저장소)에서 소스를 확인하실 수 있습니다. 여러분의 피드백을 환영합니다. 감사합니다!
15분 안에 커맨드라인 앱을 작성하며 러스트 배우기
이 튜토리얼에서는 러스트로 CLI(command line interface) 애플리케이션을 작성하는 방법에 대해 설명할 것입니다. 프로그램을 실행하게 되는 지점(챕터 1.3쯤)까지 대략 15분 정도가 걸릴 수 있습니다. 그 이후에는 이 작은 프로그램을 배포할 수 있을 때까지 프로그램을 수정해 나갈 것입니다.
여러분은 무엇을 어떻게 해야 하는지에 관한 필수적인 것들을 배우게 되며, 어디에서 더 많은 정보를 찾을 수 있는지에 대해서도 배울 것입니다. 당장 필요하지 않은 부분은 과감히 뛰어넘거나, 혹은 더 깊이 알아보세요.
어떤 종류의 프로젝트를 하고 싶나요?
먼저 간단한 것부터 시작해 봅시다:
우리는 작은 grep
클론을 작성해볼 것입니다.
이 프로그램은 문자열과 경로를 받으면 주어진 문자열을 포함한
라인만을 출력해 주는 도구입니다.
이를 grrs
라고 부릅시다. (“그래스“라고 발음합니다.)
최종적으로 우리의 프로그램은 아래와 같이 실행합니다:
$ cat test.txt
foo: 10
bar: 20
baz: 30
$ grrs foo test.txt
foo: 10
$ grrs --help
[some help text explaining the available options]
프로젝트 준비
아직 컴퓨터에 러스트를 설치하지 않았다면 설치하세요. (몇 분 정도만 걸립니다) 이어서, 터미널을 열고 애플리케이션 코드를 위치할 디렉토리로 이동하세요.
프로그래밍 프로젝트를 보관할 디렉토리에서
cargo new grrs
를 실행해 프로젝트를 시작하세요.
grrs
디렉토리가 새롭게 만들어졌다면,
일반적인 러스트 프로젝트 파일을 확인할 수 있을 것입니다:
Cargo.toml
: 프로젝트의 메타데이터와 프로젝트에서 사용할 디펜던시/외부 라이브러리 목록을 담고있는 파일.src/main.rs
: 프로그램 바이너리(main)의 엔트리포인트 파일.
grrs
디렉토리에서 cargo run
을 실행했을 때
“Hello World“가 출력된다면 모든 준비를 마친 것입니다.
이렇게 보여야 합니다
$ cargo new grrs
Created binary (application) `grrs` package
$ cd grrs/
$ cargo run
Compiling grrs v0.1.0 (/Users/pascal/code/grrs)
Finished dev [unoptimized + debuginfo] target(s) in 0.70s
Running `target/debug/grrs`
Hello, world!
커맨드라인 인자 파싱하기
우리가 만드는 CLI 도구의 일반적인 호출은 아래와 같이 보일 것입니다:
$ grrs foobar test.txt
이때 프로그램은 test.txt
를 살펴보고,
foobar
가 포함된 라인을 출력할 것입니다.
그런데 저 두 값을 어떻게 얻을 수 있을까요?
프로그램 이름 뒤에 나오는 텍스트는 보통
“커맨드라인 인자“라고 부르며,
--this
와 같이 쓰일 때는 특히 “커맨드라인 플래그“라고도 부릅니다.
내부적으로, 운영체제는 이를 문자열의 리스트로 나타냅니다.
거칠게 말해서 이때 인자들은 공백으로 분리됩니다.
주어진 인자와 그 인자를 어떻게 파싱할지에 대해 다양한 방향으로 생각해 볼 수 있습니다. 또한 여러분은 프로그램 사용자에게 어떤 인자를 어떤 형식으로 전달해야 하는지 알려줘야 합니다.
인자 얻기
표준 라이브러리에는 주어진 인자의 이터레이터를 제공하는
std::env::args()
함수가 있습니다.
첫 엔트리(0
번 인덱스)는 프로그램의 이름입니다. (예를 들어 grrs
.)
이후 인자는 사용자가 입력하는 값이 됩니다.
이 방식으로 인자를 그대로 얻기는 매우 쉽습니다 (src/main.rs
파일에서 fn main() {
다음 부분):
let pattern = std::env::args().nth(1).expect("no pattern given");
let path = std::env::args().nth(2).expect("no path given");
데이터 타입으로서의 CLI 인자
주어진 인자들을 텍스트의 묶음으로 취급하는 대신, CLI 인자를 프로그램의 입력을 표현하는 임의의 데이터 타입으로 생각해 볼 수 있습니다.
grrs foobar test.txt
를 봅시다:
여기에는 두 개의 인자가 있습니다.
첫 번째로 pattern
(찾을 문자열)이 있고,
이어서 path
(문자열을 찾을 파일)이 있습니다.
이것에 대해 무엇을 더 논의할 수 있을까요?
프로그램을 시작하기 위해서는 두 인자가 모두 필요합니다.
우리가 기본값을 지정한 적이 없으므로,
사용자는 항상 두 값을 제공할 것이라고 예상할 수 있습니다.
더 나아가, 인자의 타입에 대해
첫 번째 인자 pattern
은 문자열이 될 것이고,
두 번째 인자 path
는 파일의 경로가 될 것이라고 말할 수 있습니다.
러스트에서는 프로그램이 다루는 데이터를 중심으로 프로그램을 구성하는 것이 일반적이므로,
이러한 방식으로 CLI 인자를 처리하는 것이 매우 적합합니다.
이것부터 시작하겠습니다 (src/main.rs
파일에서 fn main() {
앞 부분):
struct Cli {
pattern: String,
path: std::path::PathBuf,
}
위 코드는 pattern
과 path
두 필드에 데이터를 저장하는
새로운 구조체(struct
)를 정의힙니다.
인자를 앞서 정의한 구조체 형태로 만들기 위해서는 프로그램에 입력된 실제 인자를 얻어야 합니다. 한 가지 방법은 운영체제로부터 얻은 문자열 리스트를 하나씩 파싱하고, 구조체를 직접 만드는 것입니다. 이는 아래와 같이 할 수 있습니다:
let pattern = std::env::args().nth(1).expect("no pattern given");
let path = std::env::args().nth(2).expect("no path given");
let args = Cli {
pattern: pattern,
path: std::path::PathBuf::from(path),
};
위 코드는 잘 동작하기는 하지만, 별로 편리하지 않습니다.
이 방법으로 --pattern="foo"
나 --pattern "foo"
와 같은 요구사항은 어떻게 지원할 수 있을까요?
--help
는 어떻게 구현해야 할까요?
Clap으로 CLI 인자 파싱하기
더 좋은 방법은 다양한 라이브러리 중 하나를 사용하는 것입니다.
커맨드라인 인자를 파싱하는 데 가장 인기있는 라이브러리는
clap
입니다.
clap
은 서브 커맨드, 셸 자동완성, 도움말 메시지 등,
여러분이 생각하는 모든 기능을 지원합니다.
먼저 Cargo.toml
파일의 [dependencies]
섹션에
clap = { version = "4.0", features = ["derive"] }
을 추가해
clap
을 가져와 봅시다.
이제 우리의 코드에 use clap::Parser;
를 추가하고,
struct Cli
바로 위에 #[derive(Parser)]
를 작성합니다.
그리고 문서화 주석도 작성해 봅시다.
아래와 같이 하면 됩니다 (src/main.rs
파일의 fn main() {
앞 부분):
use clap::Parser;
/// 파일에서 패턴을 찾고 패턴을 포함한 라인을 보여준다.
#[derive(Parser)]
struct Cli {
/// 찾을 패턴
pattern: String,
/// 읽을 파일 경로
path: std::path::PathBuf,
}
Cli
구조체 바로 아래에 main
함수가 있습니다.
프로그램이 실행되면, 프로그램은 main
함수를 호출하게 됩니다.
함수의 첫 줄은 아래와 같습니다:
fn main() {
let args = Cli::parse();
}
위 코드는 인자를 파싱해 Cli
구조체로 변환합니다.
이때 문제가 생기면 어떻게 될까요?
이 지점이 Clap을 사용하는 접근법의 아름다운 부분입니다.
Clap은 어떤 필드가 주어져야 하는지,
그 필드가 어떤 형식으로 주어져야 하는지 알고 있습니다.
Clap은 자동으로 --help
메시지를 생성해 줄 뿐만 아니라,
--output
이 아닌 --putput
을 입력한 사용자에게
에러를 제공해 줍니다.
마무리
여러분의 코드는 이제 아래와 같아야 합니다:
#![allow(unused)]
use clap::Parser;
/// 파일에서 패턴을 찾고 패턴을 포함한 라인을 보여준다.
#[derive(Parser)]
struct Cli {
/// 찾을 패턴
pattern: String,
/// 읽을 파일 경로
path: std::path::PathBuf,
}
fn main() {
let args = Cli::parse();
}
아무 인자 없이 실행하는 경우:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 10.16s
Running `target/debug/grrs`
error: The following required arguments were not provided:
<pattern>
<path>
USAGE:
grrs <pattern> <path>
For more information try --help
cargo run
을 실행할 때는 --
뒤에 인자를 전달할 수 있습니다.
$ cargo run -- some-pattern some-file
Finished dev [unoptimized + debuginfo] target(s) in 0.11s
Running `target/debug/grrs some-pattern some-file`
보다시피 프로그램은 아무것도 출력하지 않습니다. 이는 오류 없이 프로그램이 종료되었다는 것을 의미합니다.
grrs의 첫 구현
지난 챕터에서 커맨드라인 인자를 다룬 뒤
우리는 입력 데이터를 얻었고,
실제 프로그램 작성을 시작할 수 있게 되었습니다.
지금은 main
함수에 아래 한 줄만 있습니다:
let args = Cli::parse();
이제 입력받은 파일을 열어봅시다.
let content = std::fs::read_to_string(&args.path).expect("could not read file");
이제 파일의 각 라인을 순회하며 주어진 패턴을 포함하는 라인을 출력해 봅시다:
for line in content.lines() {
if line.contains(&args.pattern) {
println!("{}", line);
}
}
마무리
여러분의 코드는 이제 아래와 같아야 합니다:
#![allow(unused)]
use clap::Parser;
/// 파일에서 패턴을 찾고 패턴을 포함한 라인을 보여준다.
#[derive(Parser)]
struct Cli {
/// 찾을 패턴
pattern: String,
/// 읽을 파일 경로
path: std::path::PathBuf,
}
fn main() {
let args = Cli::parse();
let content = std::fs::read_to_string(&args.path).expect("could not read file");
for line in content.lines() {
if line.contains(&args.pattern) {
println!("{}", line);
}
}
}
cargo run -- main src/main.rs
으로 잘 동작하는지 확인해 보세요!
더 나은 에러 보고
우리는 모두 에러가 발생할 것이라는 사실을 받아들일 수밖에 없습니다. 다른 언어들과 다르게, 러스트를 사용할 때는 이 현실을 무시하기가 쉽지 않습니다. 러스트에는 예외가 없으며, 모든 발생 가능한 에러 상태는 보통 함수의 반환 타입으로 표현됩니다.
Result
read_to_string
과 같은 함수는 문자열을 반환하지 않습니다.
대신, String
이나 에러 타입 중 하나를 담은 Result
를 반환합니다.
(여기서 에러 타입은 std::io::Error
)
어떤 타입이 들어있는지 어떻게 알 수 있을까요?
Result
는 enum
이기 때문에,
match
를 이용해 확인할 수 있습니다.
#![allow(unused)] fn main() { let result = std::fs::read_to_string("test.txt"); match result { Ok(content) => { println!("File content: {}", content); } Err(error) => { println!("Oh noes: {}", error); } } }
Unwrap
이제 우리는 파일 내용에 접근할 수 있지만,
match
블록 이후로 실제 뭔가를 할 수는 없습니다.
이를 위해서는 에러 케이스를 처리해야 합니다.
이때 어려운 부분은 match
블록의 모든 분기가 같은 타입을 반환해야 한다는 점입니다.
하지만 간단한 트릭이 있습니다:
#![allow(unused)] fn main() { let result = std::fs::read_to_string("test.txt"); let content = match result { Ok(content) => { content }, Err(error) => { panic!("Can't deal with {}, just exit here", error); } }; println!("file content: {}", content); }
match 블록 이후에 content
를 문자열로 사용할 수 있습니다.
만약 result
가 에러라면 문자열은 존재하지 않게 되지만,
result
를 사용하기 전에 프로그램이 종료될 것이기 때문에 문제가 없습니다.
조금 과격해 보이지만, 매우 편리한 방법입니다.
만약 파일을 읽는 프로그램이 파일이 존재하지 않는 경우 아무것도 할 수 없다면,
프로그램 종료는 적합한 전략입니다.
여기에는 unwrap
이라는 Result
의 단축 메서드도 있습니다:
#![allow(unused)] fn main() { let content = std::fs::read_to_string("test.txt").unwrap(); }
패닉할 필요 없습니다
물론 프로그램 종료가 에러를 다루는 유일한 방법은 아닙니다.
panic!
대신 단순히 return
을 사용할 수 있습니다:
fn main() -> Result<(), Box<dyn std::error::Error>> { let result = std::fs::read_to_string("test.txt"); let content = match result { Ok(content) => { content }, Err(error) => { return Err(error.into()); } }; Ok(()) }
그러나 이렇게 하려면 함수의 반환 타입을 변경해야 합니다.
지금까지의 모든 예시에 실제로는 숨겨진 부분이 있었습니다.
바로 이 코드가 속해 있는 함수 시그니처입니다.
return
이 있는 앞 예시에서 이것이 매우 중요해집니다.
여기 전체 예시가 있습니다:
fn main() -> Result<(), Box<dyn std::error::Error>> { let result = std::fs::read_to_string("test.txt"); let content = match result { Ok(content) => { content }, Err(error) => { return Err(error.into()); } }; println!("file content: {}", content); Ok(()) }
우리의 반환 타입은 Result
입니다!
덕분에 두 번째 match 분기에서 return Err(error);
을 사용할 수 있습니다.
맨 마지막에 Ok(())
가 보이시나요?
이는 함수의 기본 반환 값이며, “결과가 정상이고, 내용은 없다“라는 의미입니다.
물음표
.unwrap()
을 호출하는 것은 match
의 에러 분기에서
panic!
을 사용하는 것과 동일한 일종의 단축어입니다.
또 다른 단축어로는 에러 분기의 return
을 위한 ?
가 있습니다.
맞아요, 물음표입니다.
Result
타입의 값에 이 연산자를 붙일 수 있고,
러스트는 내부적으로 이 연산자를 우리가 작성한
match
와 매우 비슷한 것으로 확장해 줍니다.
한번 해보세요:
fn main() -> Result<(), Box<dyn std::error::Error>> { let content = std::fs::read_to_string("test.txt")?; println!("file content: {}", content); Ok(()) }
정말 간결하죠!
맥락 제공하기
main
함수에서 ?
를 사용하여 에러를 받는 것은 괜찮지만,
최선의 방법은 아닙니다.
예를 들어:
std::fs::read_to_string("test.txt")?
를 실행할 때
test.txt
가 존재하지 않는다면,
아래와 같은 출력을 보게 될 것입니다:
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
코드가 파일 이름을 포함하지 않는다면,
어떤 파일이 NotFound
인지 말해주기가 상당히 어렵습니다.
이를 해결하는 여러 방법이 있습니다.
예를 들어, 우리만의 에러 타입을 만들 수 있습니다. 그리고 커스텀 에러 메시지를 만들면 됩니다:
#[derive(Debug)]
struct CustomError(String);
fn main() -> Result<(), CustomError> {
let path = "test.txt";
let content = std::fs::read_to_string(path)
.map_err(|err| CustomError(format!("Error reading `{}`: {}", path, err)))?;
println!("file content: {}", content);
Ok(())
}
이제, 프로그램을 실행하면 우리가 만든 커스텀 에러 메시지가 출력됩니다:
Error: CustomError("Error reading `test.txt`: No such file or directory (os error 2)")
그다지 예쁘지는 않지만, 나중에 디버그 출력을 우리 타입에 맞게 적용할 수 있습니다.
실제로 이러한 패턴은 매우 일반적입니다.
그러나 원본 에러가 아닌 문자열 표현만
저장한다는 문제가 있습니다.
이러한 문제를 해결하기 위해 주로 anyhow
라이브러리를 사용합니다.
이를 통해 CustomError
타입처럼 Context
트레잇을 이용해 설명을 추가할 수 있습니다.
더불어, 원본 에러를 저장함으로써 에러의 근본 원인을 알 수 있도록 해주는
에러 메시지 “체인“을 제공합니다.
먼저 Cargo.toml
파일의 [dependencies]
섹션에
anyhow = "1.0"
을 추가하여
anyhow
크레이트를 가져옵니다.
전체 예시는 아래와 같습니다:
use anyhow::{Context, Result};
fn main() -> Result<()> {
let path = "test.txt";
let content = std::fs::read_to_string(path)
.with_context(|| format!("could not read file `{}`", path))?;
println!("file content: {}", content);
Ok(())
}
실행하면 아래와 같이 에러가 출력됩니다:
Error: could not read file `test.txt`
Caused by:
No such file or directory (os error 2)
출력
“Hello World” 출력하기
#![allow(unused)] fn main() { println!("Hello World"); }
쉽습니다. 좋아요, 다음 주제로 넘어가죠.
println!
사용하기
여러분이 출력하고 싶은 모든 것을 println!
매크로를
통해 출력할 수 있습니다.
이 매크로는 놀라운 기능을 갖추고 있으며,
특수한 문법도 있습니다.
println!
매크로에는 첫 파라미터로 문자열 리터럴을 전달해야 합니다.
이 파라미터는 추가 인자의 값으로 채워질
플레이스홀더(placeholder)를 포함해야 합니다.
예를 들어:
#![allow(unused)] fn main() { let x = 42; println!("My lucky number is {}.", x); }
위 코드는 아래와 같은 출력을 냅니다:
My lucky number is 42.
위 코드에서 문자열에 있는 중괄호({}
)는 플레이스홀더 중 하나로,
주어진 값을 사람이 읽을 수 있는 형태로 출력하는 기본 플레이스홀더입니다.
숫자와 문자열에 대해 아주 잘 동작하지만, 모든 타입에 대해 동작하지는 않습니다.
이는 “디버그 표현“이 있는 이유이기도 한데, {:?}
처럼 플레이스홀더의 괄호를
채워서 사용할 수 있습니다.
예를 들어:
#![allow(unused)] fn main() { let xs = vec![1, 2, 3]; println!("The list is: {:?}", xs); }
위 코드는 아래와 같은 출력을 냅니다:
The list is: [1, 2, 3]
만약 자기만의 데이터 타입을 디버깅과 로깅을 위해 출력 가능하게 만들고 싶다면,
대부분의 경우 타입 정의 위에 #[derive(Debug)]
를 추가하면 됩니다.
에러 출력하기
에러는 stderr
를 통해 출력해야 합니다.
그래야 사용자나 다른 프로그램이 파이프를 통해
프로그램의 출력을 파일이나 다른 프로그램에 전달하기
쉬워집니다.
러스트에서는 이를 println!
과 eprintln!
으로 구현할 수 있으며,
전자는 stdout
으로 출력하고,
후자는 stderr
로 출력합니다.
#![allow(unused)] fn main() { println!("This is information"); eprintln!("This is an error! :("); }
출력 성능에 대한 참고사항
터미널에 뭔가를 출력하는 것은 끔찍하게 느립니다!
만약 루프에서 println!
을 사용한다면,
빠른 프로그램에서도 쉽게 보틀넥이 될 것입니다.
성능을 높이기 위한 두 가지 대응 방법이 있습니다.
첫 번째는, 터미널을 실제로 “플러시(flush)“하는
쓰기 횟수를 줄이는 방법입니다.
println!
은 보통 새로운 라인에 내용을 출력하기 위해
시스템에게 매번 터미널을 플러시해달라고 요청합니다.
그럴 필요가 없다면, stdout
을 BufWriter
에서
다루도록 래핑하면 됩니다. BufWriter
는 기본적으로
최대 8kB까지 버퍼링 할 수 있습니다.
(즉시 출력을 하고 싶을 때는 BufWriter
의
.flush()
를 호출하면 됩니다.)
#![allow(unused)] fn main() { use std::io::{self, Write}; let stdout = io::stdout(); // 글로벌 stdout 엔티티를 얻는다 let mut handle = io::BufWriter::new(stdout); // 선택사항: 버퍼로 다루도록 감싼다 writeln!(handle, "foo: {}", 42); // 에러가 신경쓰인다면 여기에 `?`를 추가한다 }
두 번째는,
stdout
(또는 stderr
)에 대한 락(lock)을 획득하고
writeln!
을 이용해 직접 출력하는 방법입니다.
이렇게 하면 시스템이 stdout
을 매번 다시 잠그고 해제하는 것을 방지할 수 있습니다.
#![allow(unused)] fn main() { use std::io::{self, Write}; let stdout = io::stdout(); // 글로벌 stdout 엔티티를 얻는다 let mut handle = stdout.lock(); // 락을 얻는다 writeln!(handle, "foo: {}", 42); // 에러가 신경쓰인다면 여기에 `?`를 추가한다 }
두 방법을 함께 사용할 수도 있습니다.
프로그래스 바 보여주기
어떤 CLI 애플리케이션은 1초 이내에 실행되기도 하지만, 어떤 애플리케이션은 수 분, 수 시간을 소요하기도 합니다. 시간이 오래 걸리는 프로그램을 작성한다면, 사용자에게 프로그램이 동작하고 있다는 것을 보여주고 싶을 수 있습니다. 이를 위해서는 상태가 업데이트되고 있다는 정보를 사용하기 쉬운 형태로 출력해줘야 합니다.
indicatif 크레이트를 사용하면 프로그램에 프로그래스 바와 작은 스피너를 추가할 수 있습니다. 여기 간단한 예시가 있습니다:
fn main() {
let pb = indicatif::ProgressBar::new(100);
for i in 0..100 {
do_hard_work();
pb.println(format!("[+] finished #{}", i));
pb.inc(1);
}
pb.finish_with_message("done");
}
로그
프로그램에서 무슨 일이 일어나는지 보다 쉽게 이해하기 위해
로그 구문을 추가하고 싶을 수 있습니다.
보통 애플리케이션을 작성할 때 쉽게 로그를 남길 수 있습니다.
로그는 반년 뒤에 프로그램을 다시 실행할 때 대단히 유용해집니다.
한편, 로그를 남기는 것은 메시지의 중요도를 명시하는 것만 빼면
println!
을 사용하는 것과 같습니다.
주로 사용하는 로그 레벨에는 error, warn, info, debug, trace 가 있습니다. (error 는 중요도가 가장 높고, trace 는 가장 낮습니다.)
애플리케이션에 간단한 로그를 남기기 위해서는 log 크레이트 (로그 레벨의 이름을 딴 매크로 포함)와 로그 출력을 작성할 때 유용한 어댑터가 필요합니다. 로그 어댑터는 매우 유연하게 상용할 수 있습니다. 예를 들어, 어댑터를 이용해 터미널이 아닌 syslog에 로그를 남길 수도 있고, 아니면 중앙 로그 서버에 로그를 남길 수도 있습니다.
우리는 CLI 애플리케이션을 작성하는 데만 집중하고 있으므로,
당장 사용하기 쉬운 어댑터는 env_logger입니다.
env_logger를 사용하면 애플리케이션의 어느 부분에 어떤 레벨의
로그를 남길지 환경 변수를 통해 명시할 수 있기 때문에 이를 “env” 로거라고 합니다.
env_logger는 로그 메시지 앞에 타임스탬프와
로그를 남긴 모듈의 이름을 붙입니다.
라이브러리도 log
를 사용할 수 있기 때문에
로그 출력을 쉽게 구성할 수 있습니다.
여기 간단한 예시가 있습니다:
use log::{info, warn};
fn main() {
env_logger::init();
info!("starting up");
warn!("oops, nothing implemented!");
}
리눅스나 macOS에서 위 코드를 src/bin/output-log.rs
파일로 작성했다면,
아래와 같이 실행할 수 있습니다:
$ env RUST_LOG=info cargo run --bin output-log
Finished dev [unoptimized + debuginfo] target(s) in 0.17s
Running `target/debug/output-log`
[2018-11-30T20:25:52Z INFO output_log] starting up
[2018-11-30T20:25:52Z WARN output_log] oops, nothing implemented!
윈도우즈 파워셸에서는 아래와 같이 실행할 수 있습니다:
$ $env:RUST_LOG="info"
$ cargo run --bin output-log
Finished dev [unoptimized + debuginfo] target(s) in 0.17s
Running `target/debug/output-log.exe`
[2018-11-30T20:25:52Z INFO output_log] starting up
[2018-11-30T20:25:52Z WARN output_log] oops, nothing implemented!
윈도우즈 CMD에서는 아래와 같이 실행합니다:
$ set RUST_LOG=info
$ cargo run --bin output-log
Finished dev [unoptimized + debuginfo] target(s) in 0.17s
Running `target/debug/output-log.exe`
[2018-11-30T20:25:52Z INFO output_log] starting up
[2018-11-30T20:25:52Z WARN output_log] oops, nothing implemented!
RUST_LOG
는 로그 설정에
사용하는 환경 변수의 이름입니다.
env_logger
에는 빌더가 있기 때문에
프로그래밍적으로 로그를 설정할 수도 있으며,
가령 기본적으로 info 레벨 메시지가 출력됩니다.
이외에도 많은 로그 어댑터가 있으며,
log
를 대체하거나 확장할 수 있는 어댑터들이 있습니다.
만약 애플리케이션에 많은 양의 로그가 필요할 것 같다면
다른 것들을 검토해보고 사용자의 삶의 질을 높여주세요.
테스트
지난 수십 년간 소프트웨어 개발 분야에서 사람들은 한 가지 진실을 발견했습니다: 테스트하지 않은 소프트웨어는 대부분 동작하지 않는다. (많은 사람들이 더 나아가서 “테스트한 소프트웨어도 대체로 동작하지 않는다“고 말합니다. 그래도 여기서 우리는 모두 낙관론자입니다. 그렇죠?) 따라서, 프로그램이 예상대로 동작하는지 확인하려면 테스트를 하는 것이 현명합니다.
한 가지 쉬운 방법은 README
파일에
프로그램이 어떻게 동작해야 하는
작성하는 것입니다.
그러면 새로운 버전을 출시할 준비가 됐을 때
README
파일을 읽어보고 프로그램이
예상대로 동작하는지 확인할 수 있습니다.
이때 프로그램이 잘못된 입력에 대해 어떻게 동작해야 하는지도
적어두면 더 엄격히 검증할 수 있습니다.
또 다른 그럴듯한 아이디어가 있습니다:
바로 코드를 작성하기 전에 README
를 작성하는 것입니다.
테스트 자동화
모든 것이 멋져 보이지만, 이 모든 것을 수동으로 해야 할까요? 수동으로 하면 시간이 오래 걸릴 수 있습니다. 또한 많은 사람들이 컴퓨터에게 자신을 위해 뭔가를 수행하도록 지시하는 것을 좋아합니다. 이제 테스트를 자동화하는 방법에 대해 살펴보겠습니다.
러스트는 빌트인 테스트 프레임워크를 갖추고 있습니다. 첫 테스트를 작성해봅시다:
fn answer() -> i32 {
42
}
#[test]
fn check_answer_validity() {
assert_eq!(answer(), 42);
}
위의 코드 스니펫을 아무 파일에나 작성하고
cargo test
를 실행하면 테스트가 시작됩니다.
여기서 핵심은 #[test]
속성입니다.
이 속성은 빌드 시스템이 위와 같은 함수를 찾고
테스트로 실행해 패닉이 일어나지 않음을
검증할 수 있도록 해줍니다.
우리는 테스트를 어떻게 작성하는지 살펴봤고, 이제 무엇을 테스트할지 알아내야 합니다. 앞서 봤듯이 함수를 어서션(assertion)하는 테스트를 작성하기는 매우 쉽습니다. 하지만 CLI 애플리케이션에는 보통 여러 함수가 있습니다. 심지어 그 함수들이 사용자의 입력을 받고, 파일을 읽으며, 출력을 냅니다.
코드를 테스트 가능하게 만들기
기능 테스트에는 두 가지의 보완적 접근법이 있습니다: 하나는 전체 애플리케이션을 빌드할 때 사용되는 작은 단위를 테스트하는 “유닛 테스트“입니다. 다른 하나는 “외부에서” 최종 애플리케이션을 테스트하는 “블랙 박스 테스트” 또는 “통합 테스트“입니다. 유닛 테스트부터 시작해 봅시다.
무엇을 테스트할지 알아내기 위해서는
프로그램의 기능을 살펴봐야 합니다.
grrs
의 주요 기능은 주어진 패턴에 일치하는 라인을 출력하는 것입니다.
따라서 정확히 이 기능 에 대한 테스트를 작성해 봅시다.
우리는 로직의 가장 중요한 부분이 동작하는지 확인해야 하고,
이를 둘러싼 어떠한 설정 코드(예를 들어 CLI 인자를 다루는 코드)에도
의존하지 않는 방식으로 테스트를 해야 합니다.
grrs
의 첫 구현에서
우리는 main
함수에 아래 코드 블록을 추가했습니다:
// ...
for line in content.lines() {
if line.contains(&args.pattern) {
println!("{}", line);
}
}
슬프게도, 이 코드는 테스트하기 어렵습니다. 일단 코드가 메인 함수 안에 있기 때문에 호출하는 것부터 쉽지 않습니다. 이 문제는 별도 함수로 코드 조각을 분리함으로써 쉽게 개선할 수 있습니다:
#![allow(unused)] fn main() { fn find_matches(content: &str, pattern: &str) { for line in content.lines() { if line.contains(pattern) { println!("{}", line); } } } }
이제 테스트에서 함수를 호출할 수 있습니다:
#[test]
fn find_a_match() {
find_matches("lorem ipsum\ndolor sit amet", "lorem");
assert_eq!( // uhhhh
이렇게 할 수 있을까요?
여기서 find_matches
는 stdout
, 즉, 터미널에 직접 결과를 출력합니다.
테스트에서는 그 출력을 쉽게 포착할 수가 없습니다.
구현한 뒤에 테스트를 작성하면 함수가 사용되는 맥락 안에 함수를 완전히
통합하게 되기 때문에 이런 문제가 자주 일어납니다.
좋습니다, 위 코드를 어떻게 테스트 가능하게 바꿀 수 있을까요?
어떻게든 출력을 포착할 방법이 필요할 것입니다.
러스트의 표준 라이브러리는 I/O (input/output)을 다루기 위한
몇 가지 깔끔한 추상화를 제공하며,
여기서는 std::io::Write
를 사용해 볼 것입니다.
std::io::Write
는 문자열 뿐만 아니라, stdout
까지 비롯한
쓰기 동작을 추상화해주는 트레잇입니다.
러스트에서 “트레잇“을 처음 들어봤다면,
아마 마음에 들 것입니다.
트레잇은 러스트의 강력한 기능 중 하나로,
자바의 인터페이스나 하스켈의 타입 클래스와 비슷하다고 생각할 수 있습니다.
(여러분이 더 친숙한 쪽으로 생각해 보세요.)
이들은 서로 다른 타입이 공유하는 동작을 추상화할 수 있도록 해줍니다.
트레잇을 사용하는 코드는 개념을
매우 범용적이면서 유연한 방식으로 표현할 수 있게 됩니다.
다만 이로 인해 코드를 읽기 어려워지기도 합니다.
겁먹지는 마세요. 수년간 러스트를 사용해 온 사람들도
범용적인 코드를 바로 작성하지는 못합니다.
그럴 때는 구체적인 용도를 생각해 보는 것이 도움 됩니다.
예를 들어, 우리는 “무언가에 쓴다“라는 동작을
추상화하고자 합니다.
std::io::Write
를 구현(“impl”)하는 타입에는
터미널의 표준 출력,
파일,
메모리 버퍼,
TCP 네트워크 커넥션 등이 있습니다.
(std::io::Write
문서에서 스크롤을 내려보면
“Implementors” 목록을 볼 수 있습니다.)
이러한 배경 지식을 바탕으로,
우리의 함수가 세 번째 파라미터를 받도록 수정해 봅시다.
파라미터는 Write
를 구현하는 타입이어야 합니다.
이를 통해
테스트에 간단한 문자열을 전달하고,
그 값을 어서션할 수 있게 됩니다.
아래는 수정된 find_matches
코드입니다:
fn find_matches(content: &str, pattern: &str, mut writer: impl std::io::Write) {
for line in content.lines() {
if line.contains(pattern) {
writeln!(writer, "{}", line);
}
}
}
새로운 파라미터는 mut writer
입니다.
(즉, “writer“라고 부르는 가변적인 값입니다.)
이 파라미터의 타입은 impl std::io::Write
이며,
이를 “Write
트레잇을 구현하는 타입을 위한 플레이스홀더“라고
읽을 수 있습니다.
또한 앞서 작성한 println!(…)
이
writeln!(writer, …)
으로 바뀌었습니다.
println!
은 writeln!
과 똑같이 동작하지만
항상 표준 출력을 사용합니다.
이제 출력을 아래와 같이 테스트할 수 있습니다:
#[test]
fn find_a_match() {
let mut result = Vec::new();
find_matches("lorem ipsum\ndolor sit amet", "lorem", &mut result);
assert_eq!(result, b"lorem ipsum\n");
}
이 함수를 애플리케이션 코드에 사용하기 위해서는
main
의 find_matches
에 세 번째 파라미터로
&mut std::io::stdout()
를 전달하도록 변경해야 합니다.
아래는 이전 챕터에서 살펴본 메인 함수가
앞서 작성한 find_matches
함수를 사용하는 예시입니다:
fn main() -> Result<()> {
let args = Cli::parse();
let content = std::fs::read_to_string(&args.path)
.with_context(|| format!("could not read file `{}`", args.path.display()))?;
find_matches(&content, &args.pattern, &mut std::io::stdout());
Ok(())
}
앞서 하나의 코드 조각을 쉽게 테스트 가능하도록 만드는 방법에 대해 알아봤습니다. 우리는,
- 애플리케이션의 핵심 부분 중 하나를 명확히 알게 되었고,
- 그 부분을 자체적인 함수로 추출했으며,
- 코드를 더욱 유연하게 만들었습니다.
처음에는 그저 코드를 테스트 가능하게 만드는 것이 목표였지만, 결과적으로 매우 자연스럽고, 재사용 가능한 러스트 코드를 얻었습니다. 놀라운 일이죠!
코드를 라이브러리와 바이너리 타겟으로 쪼개기
여기서 하나를 더 해볼 수 있습니다.
지금까지는 모든 코드를 src/main.rs
파일에만 작성했습니다.
이는 현재 프로젝트가 하나의 바이너리로 만들어진다는 것을 의미합니다.
그런데 코드를 라이브러리로도 만들 수 있습니다. 이렇게요:
find_matches
함수를 새로 만든src/lib.rs
파일에 넣습니다.fn
앞에pub
을 추가합니다. 따라서pub fn find_matches
가 됩니다.src/main.rs
에서find_matches
를 지웁니다.fn main
에서find_matches
를 호출하는 부분 앞에grrs::
를 붙입니다. 따라서grrs::find_matches(…)
가 됩니다. 이것은 우리가 방금 작성한 라이브러리의 함수를 사용한다는 의미입니다!
러스트가 프로젝트를 다루는 방식은 매우 유연하며, 크레이트의 라이브러리 부분에 어떤 내용을 작성할지 초기에 생각해보는 것이 좋습니다. 예를 들어, 애플리케이션에 특정된 로직을 위한 라이브러리를 먼저 작성한 다음, 그 라이브러리를 다른 라이브러리처럼 CLI에서 사용할 수 있습니다. 또는, 프로젝트에 여러 바이너리가 있는 경우 크레이트의 라이브러리 부분에 공통 기능을 작성할 수 있습니다.
CLI 애플리케이션을 실행해서 테스트하기
지금까지 우리는 애플리케이션의 비즈니스 로직을
테스트하기 위해 노력했고, 주요 로직이
find_matches
함수임을 알아냈습니다.
이러한 과정은 매우 가치있으며,
잘 테스트된(well-tested) 코드 베이스를 향한
훌륭한 첫 걸음이기도 합니다.
(이런 종류의 테스트를 보통 “유닛 테스트“라고 부릅니다.)
그러나 바깥 세상과 상호작용하기 위해 작성한 코드는 아직 테스트하지 않았습니다. 만약 메인 함수를 작성했는데 실수로 사용자가 제공하는 경로 인자 대신, 하드 코딩된 문자열을 사용하는 코드를 남겨뒀다고 상상해보세요. 이에 대한 테스트도 작성해야 합니다! (이 수준의 테스트를 주로 “통합 테스트” 또는 “시스템 테스트“라고 부릅니다.)
tests/cli.rs
.
우리는 여전히 함수를 작성하고
그 함수를 #[test]
로 표시하고 있습니다.
이때는 함수 안에서 무슨 일이 일어나는지만이 중요합니다.
예를 들어, 프로젝트의 메인 바이너리를
사용해 일반적인 프로그램처럼 실행하려 합니다.
여러분은 이에 대한 테스트를 새로운 디렉토리에
새 파일(tests/cli.rs
)로 집어 넣을 것입니다:
다시 돌아가서,
grrs
은 파일의 문자열을 찾는 작은 도구입니다.
우리는 앞서 일치하는 문자열을 찾는 기능에 대한 테스트를 작성했습니다.
이제 테스트할 수 있는 다른 것들에 대해 생각해봅시다.
여기 몇 가지가 있습니다.
- 파일이 존재하지 않을 때는 무슨 일이 일어나나요?
- 일치하는 문자열이 없는 경우 무엇이 출력되나요?
- 인자 하나(또는 둘 다)를 전달하지 않으면 프로그램이 에러와 함께 종료되나요?
이들은 모두 유효한 테스트 케이스입니다. 추가로, 우리는 “행복한 경우“에 대한 테스트도 케이스도 하나 작성해야 합니다. 가령, 최소 하나의 일치하는 문자열을 찾고, 그 라인을 출력하는 동작에 대한 테스트 케이스가 있습니다.
이런 종류의 테스트를 쉽게 작성하기 위해,
assert_cmd
크레이트를 사용할 것입니다.
assert_cmd
는 메인 바이너리를 실행하고,
실행된 바이너리가 어떻게 동작하는지 보여주는
간결한 도구들을 제공합니다.
더 나아가,
assert_cmd
가 테스트할 어서션을
작성할 때 도움을 받기 위하여
(그리고 훌륭한 에러 메시지를 위하여)
predicates
크레이트도 추가할 것입니다.
이 두 디펜던시는 메인 리스트에 추가하지 않고
Cargo.toml
파일의 “개발 디펜던시“에
추가합니다.
두 디펜던시가 크레이트를 개발할 때만 필요하고,
실제로 사용할 때는 필요하지 않기 때문입니다.
[dev-dependencies]
assert_cmd = "2.0.11"
predicates = "3.0.3"
많은 준비가 필요해 보이죠.
그래도 tests/cli.rs
파일을 만들며
시작해 봅시다:
use assert_cmd::prelude::*; // 명령에 메서드 추가
use predicates::prelude::*; // 어서션 작성에 사용
use std::process::Command; // 프로그램을 실행
#[test]
fn file_doesnt_exist() -> Result<(), Box<dyn std::error::Error>> {
let mut cmd = Command::cargo_bin("grrs")?;
cmd.arg("foobar").arg("test/file/doesnt/exist");
cmd.assert()
.failure()
.stderr(predicate::str::contains("could not read file"));
Ok(())
}
이 테스트는 앞서 작성한 다른 테스트들과 마찬가지로
cargo test
로 실행할 수 있습니다.
처음 실행할 때는 Command::cargo_bin("grrs")
가
메인 바이너리를 컴파일해야 하기 때문에
시간이 조금 걸릴 수 있습니다.
테스트 파일 생성하기
앞서 작성한 테스트는 입력 파일이 존재하지 않을 때 프로그램이 출력하는 에러 메시지만을 체크했습니다. 이것이 중요한 테스트이기는 하지만, 가장 중요하지는 않습니다. 이제 파일에서 찾은 일치 문자열을 실제로 출력하는지 테스트해보겠습니다!
우리가 아는 내용으로 채워진 파일이 필요합니다. 그렇다면 우리는 프로그램이 무엇을 반환해야 하는지 예상할 수 있고, 코드에서 그 예상을 체크할 수 있습니다. 프로젝트에 우리가 임의의 내용으로 채운 파일을 하나 추가하고, 이 파일을 테스트에 사용하는 방식을 생각해볼 수 있습니다. 또 다른 방식은 테스트에서 임시 파일을 생성할 수도 있습니다. 튜토리얼을 위해 후자의 접근 방식을 살펴보도록 하겠습니다. 임시 파일을 생성하는 방식은 유연하며, 파일을 변경하는 프로그램을 테스트할 때도 테스트가 잘 동작하도록 만들 수 있습니다.
임시 파일을 만들기 위해,
assert_fs
크레이트를 사용할 것입니다.
Cargo.toml
파일의 dev-dependencies
에
추가해봅시다:
assert_fs = "1.0.13"
아래는 임시 파일 (경로를 알 수 있도록 이름이 지정된 파일)을
먼저 만들고 임의의 텍스트로 채운 다음,
프로그램을 실행하여 올바를 출력을 얻을 수 있는지
확인하는 새로운 테스트 케이스입니다.
이 테스트 케이스를 다른 케이스 아래에 작성할 수 있습니다.
file
이 (함수의 끝에서) 스코프를 벗어나면,
실제 임시 파일이 자동으로 삭제됩니다.
use assert_fs::prelude::*;
#[test]
fn find_content_in_file() -> Result<(), Box<dyn std::error::Error>> {
let file = assert_fs::NamedTempFile::new("sample.txt")?;
file.write_str("A test\nActual content\nMore content\nAnother test")?;
let mut cmd = Command::cargo_bin("grrs")?;
cmd.arg("test").arg(file.path());
cmd.assert()
.success()
.stdout(predicate::str::contains("test\nAnother test"));
Ok(())
}
무엇을 테스트할 것인가?
통합 테스트를 작성하는 것이 분명 재밌을 수도 있지만, 테스트를 작성하는 데 드는 시간이 애플리케이션의 동작을 변경할 때만큼 필요할 수도 있습니다. 시간을 현명하게 사용하기 위해서는 스스로에게 무엇을 테스트해야 하는지 물어야 합니다.
사용자에게 노출되는 모든 종류의 행동에 대한 통합 테스트를 작성하는 것이 일반적으로 좋은 생각이기는 합니다. 다만 모든 엣지 케이스를 커버할 필요는 없습니다. 보통 다양한 유형에 대한 예시를 만드는 것으로 충분하며, 엣지 케이스는 유닛 테스트에 맡기면 됩니다.
여러분이 적극적으로 통제할 수 없는 것에 대해 테스트를 집중하지 않는 것도 좋은 생각입니다.
--help
가 출력하는 세세한 내용을 테스트하는 것은 좋은 생각이 아닙니다.
대신 특정 요소가 노출되는지만 테스트하는 것이 좋습니다.
프로그램의 특성에 따라,
더 많은 테스트 기법을 추가해볼 수도 있습니다.
예를 들어,
프로그램의 일부를 추출해
모든 엣지 케이스를 찾아내려고 애쓰는 동시에
유닛 테스트로 많은 양의 예시 케이스를 작성하고 있는
자신을 발견한다면, proptest
를 살펴봐야 합니다.
만약 임의의 파일을 사용하고, 그 파일을 파싱하는 프로그램을 만든다면
엣지 케이스에서 버그를 찾기 위해 fuzzer를 사용해볼 수 있습니다.
러스트 도구 패키징, 배포하기
다른 사람들에게 여러분의 프로그램을 공개할 수 있을 정도로 자신감이 생겼다면, 이제 프로그램을 패키징하고 릴리즈할 때입니다!
여기에는 몇 가지 방법이 있는데, “가장 빠르게 배포하는 방법“부터 “사용자에게 가장 편리한 방법“까지 크게 세 가지의 방법을 살펴볼 것입니다.
가장 빠른 방법: cargo publish
앱을 공개하는 가장 쉬운 방법은 cargo를 이용하는 것입니다.
프로젝트에 외부 디펜던시를 어떻게 추가하는지 기억하시나요?
cargo는 기본 “크레이트 레지스트리“인 crates.io에서 해당하는
디펜던시를 다운로드합니다.
cargo publish
를 이용하면 바이너리 타겟을 비롯한
여러분의 크레이트를 crates.io에 공개할 수 있습니다.
crates.io에 크레이트를 공개하는 것은 상당히 직관적입니다.
우선 crates.io에 계정이 없다면 가입하세요.
현재로써는 GitHub을 통해 인증을 해야 하므로,
GitHub 계정도 필요합니다.
그 다음엔, 로컬 머신에서 cargo를 이용해 로그인합니다.
이를 위해, crates.io 계정 페이지에 들어가서
새 토큰을 생성하고 cargo login <새-토큰>
을 실행하세요.
이 과정은 컴퓨터당 한 번만 하면 됩니다.
cargo의 퍼블리싱 가이드에서 더 자세한
내용을 배울 수 있습니다.
이제 cargo는 crates.io에 따라 여러분을 알게 되었고,
크레이트를 공개할 준비가 끝났습니다.
새로운 크레이트(또는 새 버전)를 공개하기에 앞서,
Cargo.toml
을 열어 필수적인 메타데이터를 추가했는지
한 번 더 봑인해 보는 것이 좋습니다.
Cargo.toml
에 설정할 수 있는 모든 필드는
cargo 매니페스트 형식에서 찾아볼 수 있습니다.
아래는 빠르게 참고할만한 일반적인 예시입니다:
[package]
name = "grrs"
version = "0.1.0"
authors = ["Your Name <your@email.com>"]
license = "MIT OR Apache-2.0"
description = "A tool to search files"
readme = "README.md"
homepage = "https://github.com/you/grrs"
repository = "https://github.com/you/grrs"
keywords = ["cli", "search", "demo"]
categories = ["command-line-utilities"]
crates.io에서 바이너리를 설치하는 방법
앞서 crates.io에 크레이트를 공개하는 방법을 살펴봤는데,
아마 어떻게 설치할 수 있는지도 궁금할 것입니다.
cargo build
(또는 이와 비슷한 명령)를 실행하면
cargo가 알아서 라이브러리를 다운로드하고 컴파일해줬지만,
바이너리를 설치할 때는 명시적으로
어떤 바이너리를 설치할지 명시해야 합니다.
바이너리 설치는 cargo install <크레이트-이름>
으로 할 수 있습니다.
이 명령은 기본적으로 크레이트를 다운로드하고,
크레이트에 포함된 모든 바이너리 타켓을 컴파일한 다음
(“릴리즈” 모드에서 시간이 조금 걸릴 수 있습니다.)
그 결과물을 ~/.cargo/bin/
디렉토리로 복사합니다.
(셸이 설치된 바이너리를 찾을 수 있는지 확인하세요!)
git 저장소를 통해 크레이트를
설치할 수도 있습니다.
또한 크레이트의 특정 바이너리만을 설치하거나,
바이너리를 설치할 대체 디렉토리도 지정할 수 있습니다.
자세한 정보는 cargo install --help
를 참고하세요.
사용할 때
cargo install
은 바이너리 크레이트를 설치하는 쉬운 방법입니다.
이는 러스트 개발자가 사용하기에 매우 편리한 방법이지만,
심각한 단점도 있습니다:
이렇게 크레이트를 설치하면 항상 소스를
밑바닥부터 컴파일하게 되며,
사용자는 자신의 기기에 바이너리를 설치하기 위해
러스트와 cargo, 그리고 여러분의 프로젝트가 요구하는
모든 시스템 디펜던시를 필요로 하게 됩니다.
거대한 러스트 코드베이스를 컴파일하면 시간이 오래 걸릴 수도 있습니다.
다른 러스트 개발자를 대상으로 도구를 배포할 때는
이렇게 하는 것이 가장 좋습니다.
예를 들어:
cargo-tree
또는 cargo-outdated
와 같은
많은 cargo 서브커맨드를 함께 설치할 수 있습니다.
바이너리 배포하기
러스트는 네이티브 코드로 컴파일되고,
기본적으로 모든 디펜던시를 정적으로 링크하는 언어입니다.
grrs
라는 바이너리를 가진 프로젝트에서
cargo build
를 실행하면,
최종적으로 grrs
라는 바이너리를 얻게 됩니다.
시도해보세요:
cargo build
로 빌드하면 바이너리 파일이 target/debug/grrs
에 만들어지고,
cargo build --release
로 빌드하면 target/release/grrs
에 만들어집니다.
대상 시스템에 특정 외부 라이브러리 설치를 필요로하는 크레이트
(시스템 버전의 OpenSSL을 사용하는 등)를 사용하는 것이 아닌 이상,
이렇게 만들어진 바이너리는 공통 시스템 라이브러리에만 의존합니다.
즉, 파일 하나를 같은 운영 체제를 사용하는 다른 사람들에게 보내면
파일을 받은 사람들이 바이너리를 실행할 수 있습니다.
이것만으로도 이미 강력합니다!
방금 본 cargo install
의 두 가지 단점을 뛰어넘을 수 있습니다:
사용자의 기기에 러스트를 설치할 필요도 없고,
컴파일하기 위해 오랜 시간을 기다릴 필요도 없습니다.
사용자는 바로 바이너리를 실행할 수 있습니다.
앞서 봤듯이, cargo build
는 우리를 위한 바이너리를 빌드합니다.
유일한 문제는 그 바이너리를 모든 플랫폼에서 작동한다고 보장할 수 없다는 것입니다.
여러분의 윈도우즈 머신에서 cargo build
를 실행한다면
기본적으로 맥에서 작동하는 바이너리를 없습니다.
모든 플랫폼에서 작동하는 바이너리를 자동으로 생성할
방법이 있을까요?
CI로 바이너리 릴리즈 빌드하기
여러분의 도구가 오픈소스이고
GitHub에서 호스트되고 있다면,
매우 쉽게 Travis CI와 같은
무료 CI(continuous integration) 서비스를 설정할 수 있습니다.
(다른 플랫폼에서 작동하는 다른 서비스들도 있지만, Travis가 유명합니다.)
CI는 기본적으로 저장소에 변경사항을 푸시할 때마다
가상 머신에서 명령을 실행합니다.
CI에서 어떤 명령을 사용할지,
어떤 종류의 머신을 사용할지
설정할 수 있습니다.
예를 들어:
러스트와 몇몇 일반적인 빌드 도구가 설치된 머신에서
cargo test
를 실행하는 것이 좋습니다.
만약 테스트가 실패한다면,
최신 변경사항에 문제가 있다는 사실을 알 수 있습니다.
CI를 통해 바이너리를 빌드하고
GitHub에 업로드할 수도 있습니다!
실제로 cargo build --release
를 실행하고
어딘가에 바이너리를 업로드하면
모든 준비가 끝납니다. 그렇죠?
사실 그렇지 않습니다.
우리가 빌드한 바이너리가
최대한 많은 시스템과 호환되는지
확인해야 합니다.
예를 들어,
리눅스에서는 현재 시스템이 아닌
x86_64-unknown-linux-musl
을 대상으로 컴파일을 하여
기본 시스템 라이브러리에 의존하지 않도록 할 수 있습니다.
macOS에서는 MACOSX_DEPLOYMENT_TARGET
을 10.7
로 설정하면
10.7 버전 이상의 시스템에만 있는 기능에만 의존하도록 할 수 있습니다.
이러한 바이너리 빌드 방법의 예시를 Linux, macOS를 대상으로 한 여기과 윈도우즈를 대상으로 한 여기에서 볼 수 있습니다.
또 다른 방법은 바이너리를 빌드할 때 필요한 모든 도구를 갖추고 있는 pre-built (도커) 이미지를 사용하는 것입니다. 이를 통해 보다 다양한 플랫폼을 쉽게 공략할 수 있습니다. trust 프로젝트에는 여러분의 프로젝트에 사용할 수 있는 스크립트와 이를 설정하는 방법에 대한 설명이 있으며, AppVeyor를 통해 윈도우즈도 지원합니다.
만약 로컬에서 모든 설정을 하고 릴리즈 파일을 자신의 컴퓨터에 생성하고 싶은 경우에도 trust를 확인해보세요. trust는 내부적으로 cross를 사용하는데, 이는 cargo와 비슷하게 동작하지만 도커 컨테이너 내부의 cargo 프로세스로 명령을 전달합니다. 여기에 사용하는 이미지 정의는 cross에서도 사용할 수 있씁니다.
바이너리 설치하는 방법
사용자에게 wasm-pack-release와 같은 릴리즈 페이지를 제공하면 사용자는 우리가 생성한 아티팩트를 다운로드할 수 있습니다. 우리가 생성한 릴리즈 아티팩트는 특별한 것이 아닙니다: 결국 바이너리를 포함한 아카이브 파일일 뿐입니다! 즉, 여러분이 만든 도구의 사용자들은 자신의 브라우저를 이용해 파일을 다운로드하고 파일의 압축을 푼 다음(보통 자동으로 됩니다.), 원하는 위치에 바이너리를 복사해 사용하게 됩니다.
이러한 과정은 수동으로 프로그램을 “설치“하는 경험을 수반하기 때문에 README 파일에 프로그램을 설치하는 방법에 대해 설명하는 섹션을 추가할 필요가 있습니다.
사용할 때
바이너리 릴리즈는 일반적으로 좋은 선택이며, 단점이 거의 없습니다. 사용자가 수동으로 도구를 설치하고 업데이트해야 한다는 문제를 해결하지는 못하지만, 러스트를 설치하지 않고 빠르게 최신 릴리즈 버전을 설치할 수 있습니다.
바이너리에 추가로 패키징할 것
이제 사용자가 릴리즈 빌드를 다운로드하면
바이너리 파일만이 포함된 .tar.gz
파일을 얻게 됩니다.
따라서 우리의 예시 프로젝트의 경우
사용자는 하나의 실행 가능한 grrs
파일을 얻습니다.
그런데 우리의 저장소에는 더 많은 파일이 있고,
사용자가 추가적인 파일을 받길 원할 수도 있습니다.
예를 들어 도구를 어떻게 사용해야 하는지 설명하는 README 파일이나
라이센스 파일을 제공할 수 있습니다.
프로젝트에 이미 파일이 있으므로,
쉽게 추가할 수 있습니다.
특히 커맨드라인 도구에 적합한 몇몇 흥미로운 파일들이 있습니다: README 파일 외에 man 페이지를 추가로 제공하거나, 셸에서 사용할 수 있는 플래그에 대한 자동완성 설정 파일을 제공하는 건 어떨까요? 이를 직접 손으로 작성할 수도 있겠지만, 우리가 사용하는 인자 파싱 라이브러리 clap은 파일을 자동으로 생성해 줍니다. 더 자세한 내용은 이 책의 더 깊은 주제에서 찾아보세요.
패키지 저장소를 통해 애플리케이션 설치하기
앞서 살펴본 두 방법은 모두 일반적으로 기기에 소프트웨어를 설치하는 방식은 아닙니다. 특히 커맨드라인 툴은 대부분의 운영체제에서 글로벌 패키지 매니저를 통해 설치합니다. 이렇게 하면 사용자는 다른 프로그램을 설치하는 것과 같은 방식으로 여러분의 프로그램을 설치할 수 있으므로, 프로그램을 설치하는 방법에 대해 신경쓸 필요가 없습니다. 이러한 패키지 매니저는 프로그램의 새 버전을 사용할 수 있게 됐을 때 사용자가 프로그램을 업데이트할 수 있도록 해줍니다.
슬프게도, 서로 다른 시스템을 지원한다는 것은
각 시스템이 어떻게 동작하는지 살펴봐야 함을 의미합니다.
어떤 경우에는 저장소에 파일을 추가하는 것만큼 쉬울 수도 있습니다.
(가령, macOS의 brew
를 사용하기 위해 이것과 같은 포뮬러(Formula) 파일을 추가할 수 있습니다.)
하지만 그 외의 경우에는 대체로 직접 패치를 전송해 해당 패키지 매니저의 저장소에
여러분의 프로그램을 추가해야 합니다.
이때 cargo-bundle,
cargo-deb,
cargo-aur과 같은 유용한 도구를 사용할 수 있습니다.
이 챕터에서는 다양한 시스템에서 이들이 어떻게 동작하는지,
어떻게 여러분의 프로그램을 올바르게 패키징할 수 있는지
설명하지는 않습니다.
대신 다양한 패키지 매니저에서 사용할 수 있는 러스트 도구를 살펴보겠습니다.
예시: ripgrep
ripgrep은 러스트로 작성된 grep
/ack
/ag
대체품입니다.
ripgrep은 매우 성공적인 프로젝트이며, 많은 운영체제에 대해 패키징되어 있습니다:
프로젝트의 README에서 “Installation” 섹션을 살펴보세요!
설치 방법에는 몇 가지 선택지가 있습니다:
먼저 GitHub 릴리즈 링크를 통해 바이너리를 직접 다운로드하는 방법이 있습니다.
또는 다양한 패키지 매니저를 통해 설치하는 방법도 있습니다.
마지막으로 cargo install
을 통해 설치하는 방법이 있습니다.
여기에 나열된 방법 중 하나를 따르지 않고
cargo install
로 바이너리 릴리즈를 추가하는 것으로 시작해
최종적으로 여러분의 도구를 시스템 패키지 매니저를 통해
배포해보는 것도 것도 좋은 생각인 것 같습니다.
더 깊은 주제
커맨드라인 애플리케이션을 작성할 때 여러분이 신경 써야 하는 부분에 대해 더욱 자세한 내용을 다루는 챕터입니다.
시그널 다루기
커맨드라인 애플리케이션과 같은 프로세스는 운영체제가 보낸 시그널에 반응해야 합니다. 시그널의 가장 흔한 예시는 아마도, 일반적으로 프로세스를 종료시킬 때 쓰는 Ctrl+C일 것 입니다. 러스트 프로그램에서 시그널을 다루기 위해서는 시그널에 반응하는 방법뿐 아니라 시그널을 수신하는 방법에 대해서도 고민해 봐야 합니다.
운영체제 간 차이점
유닉스 시스템(리눅스, macOS, FreeBSD 등)에서 프로세스는 시그널을 받을 수 있습니다. 프로세스는 시그널을 받아서 프로그램이 정의한 방식으로 시그널을 처리하는 기본적인 방법(OS가 제공)으로 시그널에 반응할 수도 있고, 시그널을 통째로 무시할 수도 있습니다.
윈도우즈에는 시그널이 없습니다. 대신 콘솔 핸들러를 이용하여 이벤트가 발생했을 때 실행되는 콜백을 정의할 수 있습니다. 또한 윈도우즈에는 0으로 나누기, 잘못된 접근, 스택오버플로우 등 모든 종류의 시스템 예외를 처리할 수 있는 구조적 예외 처리도 있습니다.
Ctrl+C 다루기
ctrlc 크레이트는 이름 그대로의 일을 합니다. ctrlc는 다양한 플랫폼에 대해 사용자가 Ctrl+C을 눌렀을 때 프로그램이 반응할 수 있도록 만들어 줍니다. ctrlc의 주요 사용법은 아래와 같습니다:
use std::{thread, time::Duration};
fn main() {
ctrlc::set_handler(move || {
println!("received Ctrl+C!");
})
.expect("Error setting Ctrl-C handler");
// 다음 코드는 실제로 동작하며, Ctrl-C로 인터럽트될 수 있다.
// 이 예시에서는 몇 초간 기다린다.
thread::sleep(Duration::from_secs(2));
}
물론 이렇게 하면 별 도움이 되지 않습니다. 메시지를 출력할 뿐 프로그램을 종료시키지는 않으니까요.
실제 프로그램의 경우,
시그널 핸들러에서 변수를 설정해
프로그램 곳곳에서 상태를 체크하는 것이 좋습니다.
예를 들어,
시그널 핸들러에서 Arc<AtomicBool>
(스레드 간에 공유할 수 있는 불리언 타입)
변수를 설정하면, 루프(hot loop)를 돌거나
스레드를 대기할 때 그 값을 주기적으로
체크하면서 true가 되면 프로그램을
종료하도록 할 수 있습니다.
다른 시그널 다루기
ctrlc 크레이트는 Ctrl+C,
혹은 유닉스 시스템에서 SIGINT
(“인터럽트” 시그널)라고 불리는
시그널만 다룰 수 있습니다.
더 많은 유닉스 시그널에 반응하기 위해서는
signal-hook를 사용해야 합니다.
signal-hook의 설계는 이 블로그 글에
설명되어 있으며, 현재 가장 광범위한 커뮤니티 지원을 받는 라이브러리입니다.
여기 간단한 예시가 있습니다:
use signal_hook::{consts::SIGINT, iterator::Signals};
use std::{error::Error, thread, time::Duration};
fn main() -> Result<(), Box<dyn Error>> {
let mut signals = Signals::new(&[SIGINT])?;
thread::spawn(move || {
for sig in signals.forever() {
println!("Received signal {:?}", sig);
}
});
// 다음 코드는 실제로 동작하며, Ctrl-C로 인터럽트될 수 있다.
// 이 예시에서는 몇 초간 기다린다.
thread::sleep(Duration::from_secs(2));
Ok(())
}
채널 사용하기
변수를 설정하고, 프로그램이 그 변수를 체크하도록 만드는 대신 채널을 사용할 수 있습니다: 채널을 만들면 시그널을 수신할 때마다 시그널 핸들러가 채널로 값을 내보내 줍니다. 애플리케이션 코드에서는 한 채널과 다른 채널을 스레드 간의 동기화 지점으로 사용하게 됩니다. crossbeam-channel을 사용하면 아래와 같은 모습이 됩니다:
use std::time::Duration;
use crossbeam_channel::{bounded, tick, Receiver, select};
use anyhow::Result;
fn ctrl_channel() -> Result<Receiver<()>, ctrlc::Error> {
let (sender, receiver) = bounded(100);
ctrlc::set_handler(move || {
let _ = sender.send(());
})?;
Ok(receiver)
}
fn main() -> Result<()> {
let ctrl_c_events = ctrl_channel()?;
let ticks = tick(Duration::from_secs(1));
loop {
select! {
recv(ticks) -> _ => {
println!("working!");
}
recv(ctrl_c_events) -> _ => {
println!();
println!("Goodbye!");
break;
}
}
}
Ok(())
}
퓨쳐(futures)와 스트림 사용하기
tokio를 사용하고 있다면,
여러분은 이미 비동기 패턴과 이벤트 주도 설계를 적용하여
애플리케이션을 작성하고 있을 확률이 높습니다.
이때는 crossbeam의 채널을 직접 사용하지 않고
signal-hook의 tokio-support
기능을 사용할 수 있습니다.
이 기능을 이용하면 signal-hook의 Signals
타입에 대해
.into_async()
를 호출하여 futures::Stream
을 구현하는
새로운 타입을 얻을 수 있습니다.
첫 Ctrl+C 시그널을 처리하는 도중 또 다른 Ctrl+C 시그널을 수신했을 때
사용자가 Ctrl+C를 누르면 여러분의 프로그램은 몇 초 뒤 종료되거나 진행 상황을 알려줄 것입니다. 만약 그렇지 않으면 사용자는 Ctrl+C을 한 번 더 누를 것입니다. 이때 일반적인 동작은 애플리케이션을 즉시 종료하는 것입니다.
설정 파일 사용하기
설정을 다루는 것은 짜증 날 수도 있습니다. 특히 다양한 운영체제를 지원해야 하는 경우 각자의 단기, 장기 보관 파일 저장 위치를 고려해야 하므로 더욱 그렇습니다.
여기엔 여러 해결 방안이 있는데, 일부는 다른 것들보다 더욱 로우 레벨의 해결책이기도 합니다.
이때 사용하기 가장 쉬운 크레이트는 confy
입니다.
confy
는 여러분의 애플리케이션 이름을 묻고
struct
(Serialize
, Deserialize
를 derive)를 통해
설정 레이아웃을 명시하도록 합니다.
이렇게만 하면 나머지는 confy
가 찾아냅니다!
#[derive(Debug, Serialize, Deserialize)]
struct MyConfig {
name: String,
comfy: bool,
foo: i64,
}
fn main() -> Result<(), io::Error> {
let cfg: MyConfig = confy::load("my_app")?;
println!("{:#?}", cfg);
Ok(())
}
물론 설정 가능성(configurability)을 포기해야 하지만,
confy
는 정말 사용하기 쉽습니다.
여러분이 간단한 설정만을 원한다면
confy
크레이트가 바로 여러분을 위한 것일 수 있습니다.
설정 환경
종료 코드
프로그램이 항상 성공적으로 동작하지는 않습니다.
에러가 발생했을 때 여러분은 필수적인 정보를 올바르게 내보내야 합니다.
사용자에게 에러에 대해 말해주기에 더해서,
대부분의 시스템에서 프로세스가 종료될 때
종료 코드를 내보냅니다.
(0에서 255까지의 정수가 대부분의 플랫폼에서 호환됩니다.)
여러분은 프로그램의 상태에 알맞은 코드를 내보내야 합니다.
예를 들어서, 프로그램이 성공적으로 동작하는
이상적인 상황에서 종료 코드는 0
이 되어야 합니다.
에러가 발생하면 조금 더 복잡해집니다.
현실에서는 프로그램에 일반적인 문제가 생겼을 때
많은 경우 종료 코드로 1
을 내보냅니다.
러스트는 프로세스에 패닉이 일어났을 때
101
을 종료 코드로 사용합니다.
이를 넘어서, 사람들은 자신의 프로그램에서 많은 것을 해왔습니다.
뭘 할 수 있을까요?
BSD 생태계는 종료 코드에 대한 공통의 정의를 모아뒀습니다.
(여기에서 찾아볼 수 있습니다.)
러스트 라이브러리 exitcode
는 이와 같은 코드를 제공하며,
여러분의 애플리케이션에서 바로 사용할 수 있습니다.
사용 가능한 값을 보려면 API 문서를 참고하세요.
Cargo.toml
에 exitcode
디펜던시를 추가한 뒤에
아래와 같이 사용할 수 있습니다:
fn main() {
// ...실제 작업...
match result {
Ok(_) => {
println!("Done!");
std::process::exit(exitcode::OK);
}
Err(CustomError::CantReadConfig(e)) => {
eprintln!("Error: {}", e);
std::process::exit(exitcode::CONFIG);
}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(exitcode::DATAERR);
}
}
}
사람과 소통하기
먼저 CLI 출력 챕터를 읽을 것을 권장합니다. CLI 출력 챕터에서는 터미널에 출력을 어떻게 내는지 설명했다면, 이 챕터에서는 무엇을 출력할지 설명합니다.
모든 것이 순조로울 때
모든 것이 순조로울 때도 사용자에게 애플리케이션의 진행 상황을 보여주는 것이 좋습니다. 이때 메시지는 간결하고 유익해야 합니다. 로그에 지나치게 기술적인 용어를 사용하지 마세요. 그리고, 애플리케이션이 충돌(crash)한 것이 아니므로 사용자가 에러를 찾아볼 필요는 없다는 사실을 기억하세요.
커뮤니케이션 스타일이 일관되어야 한다는 점이 가장 중요합니다. 로그를 쉽게 파악할 수 있도록 항상 같은 접두어와 문장 구조를 사용하세요.
애플리케이션의 출력이 지금 프로그램에 무슨 일이 일어나고 있는지, 이 일이 사용자에게 어떤 영향을 미치는지 이야기하도록 하세요. 이를 위해 단계별 타임라인을 보여줄 수도 있고, 오래 걸리는 작업에서는 프로그래스 바와 인디케이터를 보여줄 수도 있습니다. 사용자로 하여금 애플리케이션이 하는 일을 따라갈 수 있게 만들고, 프로그램이 하는 일이 비밀스럽게 느껴지지 않도록 해야 합니다.
무슨 일이 일어나는지 말하기 어려울 때
사소한 상태를 알릴 때는 일관성을 유지하는 것이 중요합니다. 많은 로그를 남기면서도 로그 레벨을 엄격히 따르지 않는 애플리케이션은 로그를 남기지 않는 애플리케이션보다 적은 정보를 제공합니다.
따라서 이벤트와 메시지의 중요도를
정의하여 일관된 로그 레벨을 사용하는 것이 중요합니다.
이러한 방식으로 사용자는 --verbose
플래그 또는
환경 변수(RUST_LOG
등)를 통해 직접 로그 양을 조절할 수 있습니다.
일반적으로 사용하는 log
크레이트는
아래와 같은 로그 레벨을 정의합니다.
(중요도 오름차순)
- trace
- debug
- info
- warning
- error
info 를 기본 로그 레벨로 설정하여 유용한 출력을 제공하는 것이 좋습니다. (더 조용한 출력 스타일을 지향하는 일부 애플리케이션은 기본적으로 경고와 에러만 보여주기도 합니다.)
추가로, 모든 로그 메시지에서 비슷한 접두어와
문장 구조를 사용하는 것은 좋은 생각입니다.
이렇게 하면 grep
과 같은 도구를 사용해 로그를 쉽게 필터링할 수 있습니다.
메시지에는 필터링된 로그에서 유용한 정보를 얻을 수 있을 정도로
충분한 맥락을 제공하되, 너무 상세한 정보를 담지는 않아야 합니다.
로그 예시
error: could not find `Cargo.toml` in `/home/you/project/`
=> Downloading repository index
=> Downloading packages...
아래는 wasm-pack의 로그 출력입니다:
[1/7] Adding WASM target...
[2/7] Compiling to WASM...
[3/7] Creating a pkg directory...
[4/7] Writing a package.json...
> [WARN]: Field `description` is missing from Cargo.toml. It is not necessary, but recommended
> [WARN]: Field `repository` is missing from Cargo.toml. It is not necessary, but recommended
> [WARN]: Field `license` is missing from Cargo.toml. It is not necessary, but recommended
[5/7] Copying over your README...
> [WARN]: origin crate has no README
[6/7] Installing WASM-bindgen...
> [INFO]: wasm-bindgen already installed
[7/7] Running WASM-bindgen...
Done in 1 second
패닉이 일어났을 때
자주 잊히는 측면 중 하나는 프로그램이 충돌할 때도 뭔가가 출력된다는 점입니다. 러스트에서 “충돌“은 대개 “패닉“을 의미합니다. (즉, “운영체제가 프로세스를 강제로 종료시킨 것“과 다르게 “통제된 충돌“입니다.) 패닉이 발생하면 기본적으로 “패닉 핸들러“가 몇 가지 정보를 콘솔에 출력합니다.
예를 들어,
cargo new --bin foo
로 새로운 바이너리 프로젝트를
생성하고 fn main
의 내용을 panic!("Hello World")
로 고치면
프로그램을 실행했을 때 아래와 같은 결과가 나오게 됩니다:
thread 'main' panicked at 'Hello, world!', src/main.rs:2:5
note: Run with `RUST_BACKTRACE=1` for a backtrace.
이 정보는 개발자에게 유용합니다.
(놀랍게도 main.rs
파일의 두 번째 줄에서 충돌이 발생했습니다.)
하지만 소스 코드를 볼 수 없는 사용자에게는 그다지
가치 있는 정보가 아닙니다.
사실 사용자 입장에서는 혼란에 더 가깝습니다.
따라서 커스텀 패닉 핸들러를 추가하여
더욱 사용자 친화적인 정보를 제공해야 합니다.
이를 위해 사용할 수 있는 라이브러리 중 하나는 human-panic입니다.
human-panic을 CLI 프로젝트에 추가하려면
main
함수의 시작 부분에서 setup_panic!()
매크로를
호출하면 됩니다:
use human_panic::setup_panic;
fn main() {
setup_panic!();
panic!("Hello world")
}
이제 사용자 친화적인 메시지가 출력됩니다. 사용자는 메시지를 읽고 어떻게 해야 하는지 알 수 있습니다:
Well, this is embarrassing.
foo had a problem and crashed. To help us diagnose the problem you can send us a crash report.
We have generated a report file at "/var/folders/n3/dkk459k908lcmkzwcmq0tcv00000gn/T/report-738e1bec-5585-47a4-8158-f1f7227f0168.toml". Submit an issue or email with the subject of "foo Crash Report" and include the report as an attachment.
- Authors: Your Name <your.name@example.com>
We take privacy seriously, and do not perform any automated error collection. In order to improve the software, we rely on people to submit reports.
Thank you kindly!
기계와 소통하기
커맨드라인 도구의 진정한 힘은 여러 도구를 결합할 때 드러납니다. 이는 새로운 사실이 아닙니다. 아래는 유닉스 철학에 나오는 문장입니다:
“모든 프로그램 출력이 아직 잘 알려지지 않은 프로그램이라고 할지라도 다른 프로그램에 대한 입력이 될 수 있게 할 것.”
프로그램이 이 기대를 충족하면 사용자가 행복해집니다. 이러한 철학을 따르기 위해 우리는 사람들을 위한 보기 좋은 출력뿐만 아니라 다른 프로그램이 필요로 하는 것을 제공해야 합니다. 어떻게 하는지 살펴봅시다.
누가 출력을 읽나요?
첫 번째 질문은: 출력이 컬러풀한 터미널 앞에 있는 사람을 위한 것인지, 또 다른 프로그램을 위한 것인지 묻는 것입니다. 이 질문에 대답하기 위해 is-terminal과 같은 크레이트를 사용할 수 있습니다:
use is_terminal::IsTerminal as _;
if std::io::stdout().is_terminal() {
println!("I'm a terminal");
} else {
println!("I'm not");
}
출력을 읽을 대상에 따라 추가적인 정보를
제공할 수 있습니다.
사람들은 보통 색깔이 있는 출력을 좋아합니다.
예를 들어 임의의 러스트 프로젝트에서
ls
를 실행하면 아래와 같은 결과를 볼 수 있을 것입니다:
$ ls
CODE_OF_CONDUCT.md LICENSE-APACHE examples
CONTRIBUTING.md LICENSE-MIT proptest-regressions
Cargo.lock README.md src
Cargo.toml convey_derive target
이 스타일은 사람을 위해 만들어졌기 때문에, 대부분의 설정에서
src
와 같은 일부 이름을 다른 색상으로 보여줌으로써
src
가 디렉토리임을 표시합니다.
그러나 이를 파일이나 cat
같은 프로그램에 파이프하면
ls
는 그에 맞는 출력을 내보냅니다.
터미널 윈도우에 알맞은 컬럼 레이아웃을 출력하는 대신
개별 행에 파일 이름을 출력합니다.
또한 여기에는 아무런 색깔도 적용되어 있지 않습니다.
$ ls | cat
CODE_OF_CONDUCT.md
CONTRIBUTING.md
Cargo.lock
Cargo.toml
LICENSE-APACHE
LICENSE-MIT
README.md
convey_derive
examples
proptest-regressions
src
target
기계를 위한 쉬운 출력 형식
역사적으로,
커맨드라인 도구가 생성하는 출력은 대부분 문자열입니다.
터미널 앞에 앉아 있는 사람이 보통 텍스트를 읽고
의미를 추론할 수 있기 때문에 문자열을 출력해도 문제가 없습니다.
하지만 프로그램에게는 그런 능력이 없습니다.
즉, 어떤 프로그램이 ls
와 같은 도구의 출력을 이해하려면
프로그래머가 ls
의 출력을 읽는 파서를 작성해야 합니다.
이는 보통 출력이 파싱하기 쉬운 형식으로
제한되어 있음을 의미합니다.
각 레코드가 개별 라인에 들어가고,
개별 라인에는 탭으로 구분된 내용이 들어가는
TSV(tab-separated values, 탭으로 구분된 값)와 같은 형식은
매우 인기있습니다.
이처럼 텍스트 라인을 기반으로 하는 단순한 형식은
grep
과 같은 도구가 ls
와 같은 다른 도구의
출력을 사용할 수 있도록 해줍니다.
| grep Cargo
는 개별 라인이 ls
에서 왔는지,
파일에서 왔는지 신경쓰지 않으며, 라인별로
필터링을 수행할 것입니다.
단점은 ls
가 제공한 모든 디렉토리를 필터링하는
간단한 grep
호출을 사용할 수 없다는 점입니다.
이를 위해서는 각 디렉토리 요소에 추가적인 데이터를 더해야 합니다.
기계를 위한 JSON 출력
TSV는 정형화된 데이터를 출력하는 간단한 방법입니다. 그러나 출력에 TSV를 사용하려면 다른 프로그램이 해당 출력에 어떤 필드가 있는지(그리고 어떤 순서인지) 미리 알고 있어야 하며, 다른 타입의 메시지를 출력하기도 어렵습니다. 예를 들어, 우리의 프로그램이 메시지를 출력해 다운로드를 기다리고 있음을 다른 프로그램에게 알리고, 이후 다운로드한 데이터에 대해 설명하는 메시지를 출력하고자 하는 경우를 생각해 볼 수 있습니다. 이 경우 두 메시지의 성격은 매우 다르며, TSV 출력으로 이를 통합해 표현하려면 둘을 구분할 방법을 고안해야 합니다. 마찬가지로 길이가 다른 두 리스트를 출력하고자 할 때도 같은 문제가 발생합니다.
그러나 대부분의 프로그래밍 언어/환경에서 쉽게 파싱 가능한 형식을 선택하는 것은 좋은 생각입니다. 그래서 지난 몇 년 동안 많은 애플리케이션이 데이터를 JSON 형식으로 출력하는 기능을 갖췄습니다. JSON은 거의 모든 언어가 파싱할 수 있는 충분히 간단한 형식이면서도 다양한 상황에 유용하게 사용할 수 있습니다. JSON은 사람이 읽을 수 있는 텍스트 형식이며, 많은 사람들이 JSON 데이터를 빠르게 파싱하고 직렬화하는 구현체를 개발했습니다.
앞서 우리는 프로그램이 출력하는 “메시지“에 대해
이야기했습니다.
이는 프로그램의 출력에 대해 생각해 보는 좋은 방법입니다.
프로그램은 단지 하나의 데이터 덩어리만 출력하지 않고
실행 중에 다양한 종류의 정보를 출력할 수 있습니다.
JSON을 출력할 때 이러한 접근법을 지원할 수 있는
쉬운 방법 중 하나는 메시지 당 하나의 JSON 문서를 작성하고
새로운 라인에 각 JSON 문서를 넣는 것입니다.
(이 방법을 때로 Line-delimited JSON라고 부릅니다.)
이를 통해 일반적인 println!
을 사용하는 것만큼 간단한 구현이 가능합니다.
아래는 serde_json의 json!
매크로를 이용해
러스트 소스코드에서 빠르게 JSON을 작성하는 간단한 예시입니다:
use clap::Parser;
use serde_json::json;
/// 파일에서 패턴을 찾고 해당하는 라인을 보여준다.
#[derive(Parser)]
struct Cli {
/// 사람이 읽을 수 있는 메시지 대신 JSON 출력
#[arg(long = "json")]
json: bool,
}
fn main() {
let args = Cli::parse();
if args.json {
println!(
"{}",
json!({
"type": "message",
"content": "Hello world",
})
);
} else {
println!("Hello world");
}
}
출력은 아래와 같습니다:
$ cargo run -q
Hello world
$ cargo run -q -- --json
{"content":"Hello world","type":"message"}
(cargo
를 -q
와 함께 실행하면 출력을 생략할 수 있습니다.
--
뒤의 인수는 프로그램으로 전달됩니다.)
실습 예시: ripgrep
ripgrep은 grep이나 ag를 대체하는 러스트 프로그램입니다. 기본적으로 아래와 같은 출력을 만들어 냅니다:
$ rg default
src/lib.rs
37: Output::default()
src/components/span.rs
6: Span::default()
그런데 --json
옵션을 주면 아래와 같이 출력됩니다:
$ rg default --json
{"type":"begin","data":{"path":{"text":"src/lib.rs"}}}
{"type":"match","data":{"path":{"text":"src/lib.rs"},"lines":{"text":" Output::default()\n"},"line_number":37,"absolute_offset":761,"submatches":[{"match":{"text":"default"},"start":12,"end":19}]}}
{"type":"end","data":{"path":{"text":"src/lib.rs"},"binary_offset":null,"stats":{"elapsed":{"secs":0,"nanos":137622,"human":"0.000138s"},"searches":1,"searches_with_match":1,"bytes_searched":6064,"bytes_printed":256,"matched_lines":1,"matches":1}}}
{"type":"begin","data":{"path":{"text":"src/components/span.rs"}}}
{"type":"match","data":{"path":{"text":"src/components/span.rs"},"lines":{"text":" Span::default()\n"},"line_number":6,"absolute_offset":117,"submatches":[{"match":{"text":"default"},"start":10,"end":17}]}}
{"type":"end","data":{"path":{"text":"src/components/span.rs"},"binary_offset":null,"stats":{"elapsed":{"secs":0,"nanos":22025,"human":"0.000022s"},"searches":1,"searches_with_match":1,"bytes_searched":5221,"bytes_printed":277,"matched_lines":1,"matches":1}}}
{"data":{"elapsed_total":{"human":"0.006995s","nanos":6994920,"secs":0},"stats":{"bytes_printed":533,"bytes_searched":11285,"elapsed":{"human":"0.000160s","nanos":159647,"secs":0},"matched_lines":2,"matches":2,"searches":2,"searches_with_match":2}},"type":"summary"}
보시다시피 각 JSON 문서는 type
필드를 포함하는 객체(맵)입니다.
이를 통해 rg
를 위한 간단한 프론트엔드를 작성할 수 있습니다.
이 프론트엔드는 문서가 주어질 때마다 내용을 읽고,
일치하는 부분(또는 일치하는 파일)을 표시해줍니다.
이 과정은 심지어 ripgrep이 여전히 검색 중일 때도 가능합니다.
파이프된 입력을 다루는 방법
파일의 단어수를 세는 프로그램이 있다고 생각해봅시다:
use clap::Parser;
use std::path::PathBuf;
/// 파일의 라인수를 센다
#[derive(Parser)]
#[command(arg_required_else_help = true)]
struct Cli {
/// 읽을 파일의 경로
file: PathBuf,
}
fn main() {
let args = Cli::parse();
let mut word_count = 0;
let file = args.file;
for line in std::fs::read_to_string(&file).unwrap().lines() {
word_count += line.split(' ').count();
}
println!("Words in {}: {}", file.to_str().unwrap(), word_count)
}
이 프로그램은 파일의 경로르 받아 라인별로 읽고 공백으로 구분된 단어의 개수를 셉니다.
프로그램을 실행하면 파일에 있는 총 단어수가 출력됩니다:
$ cargo run README.md
Words in README.md: 47
이 프로그램이 파이프로 전달받은 파일의 단어수를 세도록 하려면 어떻게 해야 할까요? 러스트 프로그램은 Stdin 구조체를 통해 전달받은 데이터를 읽을 수 있습니다. 이 구조체는 표준 라이브러리의 stdin 함수를 통해 얻을 수 있습니다. 파일의 라인을 읽는 것처럼 stdin의 라인을 읽을 수 있습니다.
아래는 stdin을 통해 파이프된 데이터의 단어수를 세는 프로그램입니다:
use clap::{CommandFactory, Parser};
use is_terminal::IsTerminal as _;
use std::{
fs::File,
io::{stdin, BufRead, BufReader},
path::PathBuf,
};
/// 파일 또는 stdin의 라인 수를 센다
#[derive(Parser)]
#[command(arg_required_else_help = true)]
struct Cli {
/// 읽을 파일의 경로, - 를 사용하면 stdin에서 읽음 (tty는 안 됨)
file: PathBuf,
}
fn main() {
let args = Cli::parse();
let word_count;
let mut file = args.file;
if file == PathBuf::from("-") {
if stdin().is_terminal() {
Cli::command().print_help().unwrap();
::std::process::exit(2);
}
file = PathBuf::from("<stdin>");
word_count = words_in_buf_reader(BufReader::new(stdin().lock()));
} else {
word_count = words_in_buf_reader(BufReader::new(File::open(&file).unwrap()));
}
println!("Words from {}: {}", file.to_string_lossy(), word_count)
}
fn words_in_buf_reader<R: BufRead>(buf_reader: R) -> usize {
let mut count = 0;
for line in buf_reader.lines() {
count += line.unwrap().split(' ').count()
}
count
}
만약 텍스트를 파이프로 전달하여 프로그램을 실행할 때는, -
가
stdin
으로부터 데이터를 읽어들인다는 것을 의미합니다.
이 프로그램은 단어수를 출력합니다:
$ echo "hi there friend" | cargo run -- -
Words from stdin: 3
이 프로그램은 런타임에 입력된 텍스트가 아닌, 파이프된 입력을 예상하기 때문에 인터랙티브하지 않은 stdin을 요구합니다. 만약 stdin이 tty라면 프로그램은 작동하지 않는 이유를 알려주기 위해 도움말 문서를 출력합니다.
CLI 앱을 위한 문서 렌더링하기
CLI를 위한 문서는 보통
명령의 --help
섹션이나
매뉴얼(man
) 페이지로 구성됩니다.
clap
을 사용하면
clap_mangen
크레이트를 통해
둘 다 자동으로 생성할 수 있습니다.
#[derive(Parser)]
pub struct Head {
/// 로드할 파일
pub file: PathBuf,
/// 출력할 라인 개수
#[arg(short = "n", default_value = "5")]
pub count: usize,
}
두 번째로, 컴파일 타임에 코드에 있는
앱의 정의로부터 매뉴얼 파일을 생성하려면 build.rs`를 사용해야 합니다.
바이너리 패키징 방식 등 고려해야 할 사항이 있지만,
지금은 src
폴더 옆에 man
을 두도록 하겠습니다.
use clap::CommandFactory;
#[path="src/cli.rs"]
mod cli;
fn main() -> std::io::Result<()> {
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").ok_or_else(|| std::io::ErrorKind::NotFound)?);
let cmd = cli::Head::command();
let man = clap_mangen::Man::new(cmd);
let mut buffer: Vec<u8> = Default::default();
man.render(&mut buffer)?;
std::fs::write(out_dir.join("head.1"), buffer)?;
Ok(())
}
이제 애플리케이션을 컴파일하면 프로젝트 디렉토리에
head.1
파일이 만들어집니다.
man
에서 해당 파일을 열면 공짜 문서에 감탄할 것입니다.
자료
협업 / 도움
이 책에서 참조한 크레이트
- anyhow - 쉬운 에러 처리를 위한
anyhow::Error
제공 - assert_cmd - CLI 통합 테스트 간소화
- assert_fs - 입력 파일 설정 및 출력 파일 테스트
- clap-verbosity-flag - clap CLI에
--verbose
플래그 추가 - clap - 커맨드라인 인자 파서
- confy - 보일러플레이트 없는 설정 관리
- crossbeam-channel - 메시지 패싱을 위한 다중 생산자, 다중 소비자 채널 제공
- ctrlc - 쉬운 ctrl-c 핸들러
- env_logger - 환경 변수를 통해 설정 가능한 로거 구현
- exitcode - 시스템 종료 코드 상수
- human-panic - 패닉 메시지 핸들러
- indicatif - 프로그래스 바와 스피너
- is-terminal - 애플리케이션이 tty에서 실행 중인지 감지
- log - 구현에 대한 로그 추상화 제공
- predicates - 불리언 값으로 평가되는 예측 함수 구현
- proptest - 속성 기반 테스트 프레임워크
- serde_json - JSON 직렬화/역직렬화
- signal-hook - UNIX 시그널 처리
- tokio - 비동기 런타임
- wasm-pack - WebAssembly 빌드를 위한 도구
다른 크레이트
러스트 크레이트는 수시로 변화하기 때문에, 크레이트를 찾는 좋은 장소로는 lib.rs 크레이트 인덱스가 있습니다. 여기에는 아래와 같은 내용이 있습니다:
- Command-line interface
- Configuration
- Database interfaces
- Encoding
- Filesystem
- HTTP Client
- Operating systems
다른 자료: